From 84cb409bcc3e7f11808828b4adbd2e42cf5c9598 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 09:36:38 +0300 Subject: [PATCH 01/38] Remove the 2.x implementation Version 3 is a rewrite: the component generators and their stubs, the migration commands, the support traits and the 2.x test suite go away. --- .php-cs-fixer.dist.php | 39 -- config/config.php | 229 -------- phpstan-baseline.neon | 3 - phpunit.xml | 17 - src/Commands/BaseCommand.php | 187 ------- src/Commands/BaseMigrationCommand.php | 71 --- src/Commands/CacheClearCommand.php | 31 -- src/Commands/CacheCommand.php | 31 -- src/Commands/Generators/ActionMakeCommand.php | 144 ----- .../BaseComponentGeneratorCommand.php | 475 ---------------- .../Generators/CommandMakeCommand.php | 73 --- .../Generators/ControllerMakeCommand.php | 70 --- src/Commands/Generators/DTOMakeCommand.php | 64 --- src/Commands/Generators/EventMakeCommand.php | 59 -- .../Generators/ExceptionMakeCommand.php | 59 -- .../Generators/FactoryMakeCommand.php | 70 --- src/Commands/Generators/JobMakeCommand.php | 66 --- .../Generators/ListenerMakeCommand.php | 75 --- src/Commands/Generators/MailMakeCommand.php | 76 --- .../Generators/MiddlewareMakeCommand.php | 59 -- .../Generators/MigrationMakeCommand.php | 182 ------- src/Commands/Generators/ModelMakeCommand.php | 76 --- src/Commands/Generators/ModuleMakeCommand.php | 413 -------------- .../Generators/NotificationMakeCommand.php | 66 --- .../Generators/ObserverMakeCommand.php | 70 --- src/Commands/Generators/PolicyMakeCommand.php | 87 --- .../Generators/ProviderMakeCommand.php | 129 ----- .../Generators/QueryWizardMakeCommand.php | 72 --- .../Generators/RequestMakeCommand.php | 109 ---- .../Generators/ResourceMakeCommand.php | 67 --- src/Commands/Generators/RouteMakeCommand.php | 165 ------ src/Commands/Generators/RuleMakeCommand.php | 59 -- src/Commands/Generators/SeederMakeCommand.php | 84 --- src/Commands/Generators/TestMakeCommand.php | 121 ----- .../Generators/stubs/action/create.stub | 27 - .../Generators/stubs/action/delete.stub | 25 - .../Generators/stubs/action/list.stub | 28 - .../Generators/stubs/action/plain.stub | 15 - .../Generators/stubs/action/update.stub | 32 -- .../Generators/stubs/action/view.stub | 25 - src/Commands/Generators/stubs/command.stub | 30 - .../Generators/stubs/composer.json.stub | 29 - .../Generators/stubs/controller/api.stub | 14 - .../Generators/stubs/controller/web.stub | 13 - src/Commands/Generators/stubs/dto.stub | 13 - src/Commands/Generators/stubs/event.stub | 25 - src/Commands/Generators/stubs/exception.stub | 10 - src/Commands/Generators/stubs/factory.stub | 33 -- src/Commands/Generators/stubs/job/plain.stub | 26 - src/Commands/Generators/stubs/job/queued.stub | 30 - .../Generators/stubs/listener/plain.stub | 24 - .../Generators/stubs/listener/queued.stub | 28 - src/Commands/Generators/stubs/mail.stub | 52 -- src/Commands/Generators/stubs/middleware.stub | 20 - .../Generators/stubs/migration/add.stub | 28 - .../Generators/stubs/migration/create.stub | 28 - .../Generators/stubs/migration/delete.stub | 28 - .../Generators/stubs/migration/pivot.stub | 30 - .../Generators/stubs/migration/plain.stub | 24 - src/Commands/Generators/stubs/model/full.stub | 33 -- .../Generators/stubs/model/plain.stub | 10 - .../Generators/stubs/notification/plain.stub | 36 -- .../Generators/stubs/notification/queued.stub | 54 -- src/Commands/Generators/stubs/observer.stub | 48 -- .../Generators/stubs/policy/full.stub | 66 --- .../Generators/stubs/policy/plain.stub | 8 - .../Generators/stubs/provider/event.stub | 23 - .../Generators/stubs/provider/module.stub | 102 ---- .../Generators/stubs/provider/plain.stub | 24 - .../Generators/stubs/provider/route.stub | 57 -- .../stubs/query-wizard/eloquent.stub | 77 --- .../Generators/stubs/query-wizard/model.stub | 51 -- .../Generators/stubs/request/create.stub | 28 - .../Generators/stubs/request/delete.stub | 20 - .../Generators/stubs/request/list.stub | 20 - .../Generators/stubs/request/plain.stub | 18 - .../Generators/stubs/request/update.stub | 28 - .../Generators/stubs/request/view.stub | 20 - .../Generators/stubs/resource/collection.stub | 17 - .../Generators/stubs/resource/single.stub | 17 - src/Commands/Generators/stubs/route.stub | 7 - src/Commands/Generators/stubs/rule.stub | 19 - .../Generators/stubs/seeder/permissions.stub | 45 -- .../Generators/stubs/seeder/plain.stub | 13 - .../Generators/stubs/test/api/create.stub | 79 --- .../Generators/stubs/test/api/delete.stub | 61 --- .../Generators/stubs/test/api/list.stub | 57 -- .../Generators/stubs/test/api/plain.stub | 27 - .../Generators/stubs/test/api/update.stub | 99 ---- .../Generators/stubs/test/api/view.stub | 67 --- .../Generators/stubs/test/cli/plain.stub | 16 - .../Generators/stubs/test/feature/plain.stub | 16 - .../Generators/stubs/test/unit/plain.stub | 16 - .../Generators/stubs/test/web/create.stub | 49 -- .../Generators/stubs/test/web/delete.stub | 50 -- .../Generators/stubs/test/web/plain.stub | 16 - .../Generators/stubs/test/web/update.stub | 66 --- src/Commands/ListCommand.php | 38 -- src/Commands/MigrateCommand.php | 55 -- src/Commands/MigrateRefreshCommand.php | 65 --- src/Commands/MigrateResetCommand.php | 41 -- src/Commands/MigrateRollbackCommand.php | 45 -- src/Commands/MigrateStatusCommand.php | 44 -- src/Commands/ModuleDeleteCommand.php | 69 --- src/Commands/StubPublishCommand.php | 54 -- src/Commands/SyncCommand.php | 49 -- src/Enums/ModuleComponentType.php | 41 -- src/Exceptions/CannotDeleteModule.php | 17 - src/Exceptions/ComposerException.php | 11 - .../DirectoryMustBePresentAndWritable.php | 16 - src/Exceptions/InvalidClassName.php | 17 - src/Exceptions/InvalidConfigValue.php | 26 - src/Exceptions/InvalidTableName.php | 17 - src/Exceptions/ModuleHasNoNamespace.php | 16 - .../ModuleHasNonUniquePackageName.php | 16 - src/Exceptions/ModuleNotFound.php | 36 -- src/Exceptions/NameIsReserved.php | 16 - src/Module.php | 165 ------ src/ModulesRepository.php | 406 -------------- src/Providers/ComposerServiceProvider.php | 31 -- src/Providers/ConsoleServiceProvider.php | 74 --- .../ModulesRepositoryServiceProvider.php | 50 -- src/Providers/ModulesServiceProvider.php | 28 - src/Support/Composer.php | 97 ---- src/Support/ComposerJsonFile.php | 215 -------- .../Concerns/CanLoadRoutesFromDirectory.php | 55 -- src/Support/Concerns/CanRunModuleSeeders.php | 158 ------ .../Concerns/InteractsWithTestUser.php | 186 ------- .../Concerns/WithJsonResponseHelpers.php | 33 -- src/Support/Facades/Modules.php | 38 -- src/Support/Generator/GeneratorHelper.php | 214 -------- src/Support/Generator/GeneratorPath.php | 37 -- src/Support/Generator/Stub.php | 120 ---- src/Support/Migrations/NameParser.php | 141 ----- src/Support/Migrations/SchemaParser.php | 244 --------- src/Support/ModuleConfigWriter.php | 96 ---- src/Support/ModuleServiceProvider.php | 111 ---- tests/Commands/BaseCommandTest.php | 513 ------------------ tests/Commands/CacheClearCommandTest.php | 13 - tests/Commands/CacheCommandTest.php | 13 - .../Generators/ActionMakeCommandTest.php | 106 ---- .../BaseComponentGeneratorCommandTest.php | 160 ------ .../Generators/CommandMakeCommandTest.php | 24 - .../Generators/ControllerMakeCommandTest.php | 36 -- .../Generators/DTOMakeCommandTest.php | 22 - .../Generators/EventMakeCommandTest.php | 22 - .../Generators/ExceptionMakeCommandTest.php | 50 -- .../Generators/FactoryMakeCommandTest.php | 23 - .../Generators/JobMakeCommandTest.php | 36 -- .../Generators/ListenerMakeCommandTest.php | 38 -- .../Generators/MailMakeCommandTest.php | 24 - .../Generators/MiddlewareMakeCommandTest.php | 22 - .../Generators/MigrationMakeCommandTest.php | 231 -------- .../Generators/ModelMakeCommandTest.php | 36 -- .../Generators/ModuleMakeCommandTest.php | 152 ------ .../NotificationMakeCommandTest.php | 36 -- .../Generators/ObserverMakeCommandTest.php | 23 - .../Generators/PolicyMakeCommandTest.php | 40 -- .../Generators/ProviderMakeCommandTest.php | 77 --- .../Generators/QueryWizardMakeCommandTest.php | 36 -- .../Generators/RequestMakeCommandTest.php | 162 ------ .../Generators/ResourceMakeCommandTest.php | 36 -- .../Generators/RouteMakeCommandTest.php | 214 -------- .../Generators/RuleMakeCommandTest.php | 22 - .../Generators/SeederMakeCommandTest.php | 45 -- .../Generators/TestMakeCommandTest.php | 209 ------- tests/Commands/ListCommandTest.php | 26 - tests/Commands/MigrateCommandTest.php | 37 -- tests/Commands/MigrateRefreshCommandTest.php | 37 -- tests/Commands/MigrateResetCommandTest.php | 37 -- tests/Commands/MigrateRollbackCommandTest.php | 37 -- tests/Commands/MigrateStatusCommandTest.php | 24 - tests/Commands/ModuleDeleteCommandTest.php | 106 ---- tests/Commands/StubPublishCommandTest.php | 18 - tests/Commands/SyncCommandTest.php | 62 --- tests/ModuleTest.php | 272 ---------- tests/ModulesRepositoryTest.php | 476 ---------------- tests/Pest.php | 55 -- tests/Support/ComposerJsonFileTest.php | 85 --- tests/Support/ComposerTest.php | 59 -- .../CanLoadRoutesFromDirectoryTest.php | 41 -- .../Concerns/CanRunModuleSeedersTest.php | 82 --- .../Support/Generator/GeneratorHelperTest.php | 134 ----- tests/TestCase.php | 208 ------- ...ndTest__it_generates_a__api__module__2.yml | 33 -- ...dTest__it_generates_a__base__module__2.yml | 8 - ...Test__it_generates_a__plain__module__2.yml | 3 - ..._publish_all_laraneat_modules_stubs__1.yml | 63 --- ...ates__create__action_for_the_module__1.php | 27 - ...ates__delete__action_for_the_module__1.php | 25 - ...erates__list__action_for_the_module__1.php | 28 - ...rates__plain__action_for_the_module__1.php | 15 - ...ates__update__action_for_the_module__1.php | 32 -- ...erates__view__action_for_the_module__1.php | 25 - ...ates_console_command_for_the_module__1.php | 30 - ...tes__api__controller_for_the_module__1.php | 14 - ...tes__web__controller_for_the_module__1.php | 13 - ...st__it_generates_dto_for_the_module__1.php | 13 - ...__it_generates_event_for_the_module__1.php | 25 - ..._generates_exception_for_the_module__1.php | 10 - ...it_generates_factory_for_the_module__1.php | 33 -- ...enerates__plain__job_for_the_module__1.php | 26 - ...nerates__queued__job_for_the_module__1.php | 30 - ...tes__plain__listener_for_the_module__1.php | 24 - ...es__queued__listener_for_the_module__1.php | 28 - ...t__it_generates_mail_for_the_module__1.php | 52 -- ...generates_middleware_for_the_module__1.php | 20 - ...ates__add__migration_for_the_module__1.php | 30 - ...s__create__migration_for_the_module__1.php | 33 -- ...s__delete__migration_for_the_module__1.php | 30 - ...ates__add__migration_for_the_module__1.php | 36 -- ...ation_for_the_module_without_fields__1.php | 28 - ...s__create__migration_for_the_module__1.php | 33 -- ...ation_for_the_module_without_fields__1.php | 28 - ...s__delete__migration_for_the_module__1.php | 36 -- ...ation_for_the_module_without_fields__1.php | 28 - ...es__pivot__migration_for_the_module__1.php | 30 - ...es__plain__migration_for_the_module__1.php | 24 - ..._the_stub_is_not_recognized_by_name__1.php | 24 - ...s_model_with_factory_for_the_module__1.php | 33 -- ...enerates_plain_model_for_the_module__1.php | 10 - ...he_passed_module_name_is_not_valid__1.json | 45 -- ...he_same_folder_name_already_exists__1.json | 45 -- ...e_same_package_name_already_exists__1.json | 45 -- ...dTest__it_generates_a__api__module__1.json | 65 --- ...dTest__it_generates_a__api__module__10.php | 32 -- ...dTest__it_generates_a__api__module__11.php | 25 - ...dTest__it_generates_a__api__module__12.php | 13 - ...dTest__it_generates_a__api__module__13.php | 13 - ...dTest__it_generates_a__api__module__14.php | 33 -- ...dTest__it_generates_a__api__module__15.php | 66 --- ...dTest__it_generates_a__api__module__16.php | 102 ---- ...dTest__it_generates_a__api__module__17.php | 57 -- ...dTest__it_generates_a__api__module__18.php | 51 -- ...dTest__it_generates_a__api__module__19.php | 77 --- ...dTest__it_generates_a__api__module__20.php | 28 - ...dTest__it_generates_a__api__module__21.php | 20 - ...dTest__it_generates_a__api__module__22.php | 20 - ...dTest__it_generates_a__api__module__23.php | 28 - ...dTest__it_generates_a__api__module__24.php | 20 - ...dTest__it_generates_a__api__module__25.php | 17 - ...dTest__it_generates_a__api__module__26.php | 7 - ...dTest__it_generates_a__api__module__27.php | 7 - ...dTest__it_generates_a__api__module__28.php | 7 - ...dTest__it_generates_a__api__module__29.php | 7 - ...dTest__it_generates_a__api__module__3.json | 32 -- ...dTest__it_generates_a__api__module__30.php | 7 - ...dTest__it_generates_a__api__module__31.php | 79 --- ...dTest__it_generates_a__api__module__32.php | 61 --- ...dTest__it_generates_a__api__module__33.php | 57 -- ...dTest__it_generates_a__api__module__34.php | 99 ---- ...dTest__it_generates_a__api__module__35.php | 67 --- ...ndTest__it_generates_a__api__module__4.php | 33 -- ...ndTest__it_generates_a__api__module__5.php | 28 - ...ndTest__it_generates_a__api__module__6.php | 45 -- ...ndTest__it_generates_a__api__module__7.php | 27 - ...ndTest__it_generates_a__api__module__8.php | 25 - ...ndTest__it_generates_a__api__module__9.php | 28 - ...Test__it_generates_a__base__module__1.json | 65 --- ...Test__it_generates_a__base__module__10.php | 57 -- ...Test__it_generates_a__base__module__3.json | 32 -- ...dTest__it_generates_a__base__module__4.php | 33 -- ...dTest__it_generates_a__base__module__5.php | 28 - ...dTest__it_generates_a__base__module__6.php | 45 -- ...dTest__it_generates_a__base__module__7.php | 33 -- ...dTest__it_generates_a__base__module__8.php | 66 --- ...dTest__it_generates_a__base__module__9.php | 102 ---- ...est__it_generates_a__plain__module__1.json | 65 --- ...est__it_generates_a__plain__module__3.json | 32 -- ...Test__it_generates_a__plain__module__4.php | 102 ---- ...Test__it_generates_a__plain__module__5.php | 57 -- .../ModuleTest__it_can_add_aliases__1.json | 31 -- .../ModuleTest__it_can_add_aliases__2.json | 33 -- ...add_aliases_via_ModuleConfigWriter__1.json | 31 -- ...add_aliases_via_ModuleConfigWriter__2.json | 32 -- .../ModuleTest__it_can_add_providers__1.json | 31 -- .../ModuleTest__it_can_add_providers__2.json | 33 -- ...d_providers_via_ModuleConfigWriter__1.json | 31 -- ...d_providers_via_ModuleConfigWriter__2.json | 32 -- .../ModuleTest__it_can_set_aliases__1.json | 31 -- .../ModuleTest__it_can_set_aliases__2.json | 32 -- .../ModuleTest__it_can_set_providers__1.json | 31 -- .../ModuleTest__it_can_set_providers__2.json | 31 -- ...ate_aliases_via_ModuleConfigWriter__1.json | 31 -- ...ate_aliases_via_ModuleConfigWriter__2.json | 32 -- ...e_providers_via_ModuleConfigWriter__1.json | 31 -- ...e_providers_via_ModuleConfigWriter__2.json | 31 -- ..._it_can_sync_modules_with_composer__1.json | 45 -- ..._it_can_sync_modules_with_composer__2.json | 69 --- ..._plain__notification_for_the_module__1.php | 36 -- ...queued__notification_for_the_module__1.php | 54 -- ...t_generates_observer_for_the_module__1.php | 48 -- ...enerates_full_policy_for_the_module__1.php | 66 --- ...nerates_plain_policy_for_the_module__1.php | 8 - ...tes__event__provider_for_the_module__1.php | 23 - ...es__module__provider_for_the_module__1.php | 102 ---- ...tes__plain__provider_for_the_module__1.php | 24 - ...tes__route__provider_for_the_module__1.php | 57 -- ...oquent__query_wizard_for_the_module__1.php | 77 --- ..._model__query_wizard_for_the_module__1.php | 51 -- ..._create__api_request_for_the_module__1.php | 28 - ..._create__web_request_for_the_module__1.php | 28 - ..._delete__api_request_for_the_module__1.php | 20 - ..._delete__web_request_for_the_module__1.php | 20 - ...s__list__api_request_for_the_module__1.php | 20 - ...__plain__api_request_for_the_module__1.php | 18 - ...__plain__web_request_for_the_module__1.php | 18 - ..._update__api_request_for_the_module__1.php | 28 - ..._update__web_request_for_the_module__1.php | 28 - ...s__view__api_request_for_the_module__1.php | 20 - ...collection__resource_for_the_module__1.php | 17 - ...es__single__resource_for_the_module__1.php | 17 - ...s__delete__api_route_for_the_module__1.php | 7 - ...s__delete__web_route_for_the_module__1.php | 7 - ...ates__get__api_route_for_the_module__1.php | 7 - ...ates__get__web_route_for_the_module__1.php | 7 - ...__options__api_route_for_the_module__1.php | 7 - ...__options__web_route_for_the_module__1.php | 7 - ...es__patch__api_route_for_the_module__1.php | 7 - ...es__patch__web_route_for_the_module__1.php | 7 - ...tes__post__api_route_for_the_module__1.php | 7 - ...tes__post__web_route_for_the_module__1.php | 7 - ...ates__put__api_route_for_the_module__1.php | 7 - ...ates__put__web_route_for_the_module__1.php | 7 - ...t__it_generates_rule_for_the_module__1.php | 19 - ..._permissions__seeder_for_the_module__1.php | 45 -- ...rates__plain__seeder_for_the_module__1.php | 13 - ...enerates__unit__test_for_the_module__1.php | 16 - ...es_create__api__test_for_the_module__1.php | 79 --- ...es_create__web__test_for_the_module__1.php | 49 -- ...es_delete__api__test_for_the_module__1.php | 61 --- ...es_delete__web__test_for_the_module__1.php | 50 -- ...ates_list__api__test_for_the_module__1.php | 57 -- ...tes_plain__api__test_for_the_module__1.php | 27 - ...tes_plain__cli__test_for_the_module__1.php | 16 - ...plain__feature__test_for_the_module__1.php | 16 - ...tes_plain__web__test_for_the_module__1.php | 16 - ...es_update__api__test_for_the_module__1.php | 99 ---- ...es_update__web__test_for_the_module__1.php | 66 --- ...ates_view__api__test_for_the_module__1.php | 67 --- tests/fixtures/laravel/.gitignore | 2 - tests/fixtures/laravel/app/.gitkeep | 0 tests/fixtures/laravel/bootstrap/app.php | 22 - .../laravel/bootstrap/cache/.gitignore | 2 - tests/fixtures/laravel/composer.json | 45 -- tests/fixtures/laravel/config/app.php | 234 -------- tests/fixtures/laravel/config/auth.php | 111 ---- .../fixtures/laravel/config/broadcasting.php | 64 --- tests/fixtures/laravel/config/cache.php | 110 ---- tests/fixtures/laravel/config/cors.php | 34 -- tests/fixtures/laravel/config/database.php | 153 ------ tests/fixtures/laravel/config/filesystems.php | 73 --- tests/fixtures/laravel/config/hashing.php | 52 -- tests/fixtures/laravel/config/logging.php | 105 ---- tests/fixtures/laravel/config/mail.php | 118 ---- tests/fixtures/laravel/config/queue.php | 93 ---- tests/fixtures/laravel/config/sanctum.php | 51 -- tests/fixtures/laravel/config/services.php | 33 -- tests/fixtures/laravel/config/session.php | 201 ------- tests/fixtures/laravel/config/view.php | 36 -- tests/fixtures/laravel/database/.gitignore | 1 - .../laravel/database/factories/.gitkeep | 0 .../laravel/database/migrations/.gitkeep | 0 .../laravel/database/seeders/.gitkeep | 0 ...12_000000_testbench_create_users_table.php | 36 -- ...testbench_create_password_resets_table.php | 32 -- ...000_testbench_create_failed_jobs_table.php | 36 -- tests/fixtures/laravel/public/.gitignore | 3 - tests/fixtures/laravel/public/index.php | 55 -- .../laravel/resources/lang/en/auth.php | 20 - .../laravel/resources/lang/en/pagination.php | 19 - .../laravel/resources/lang/en/passwords.php | 22 - .../laravel/resources/lang/en/validation.php | 158 ------ .../resources/views/errors/503.blade.php | 43 -- .../laravel/resources/views/welcome.blade.php | 39 -- tests/fixtures/laravel/routes/.gitignore | 2 - tests/fixtures/laravel/server.php | 21 - tests/fixtures/laravel/storage/app/.gitignore | 0 .../laravel/storage/app/modules/modules.used | 1 - .../laravel/storage/app/public/.gitignore | 0 .../laravel/storage/framework/.gitignore | 2 - .../storage/framework/cache/.gitignore | 3 - .../laravel/storage/framework/data/.gitignore | 2 - .../storage/framework/sessions/.gitignore | 2 - .../storage/framework/testing/.gitignore | 2 - .../storage/framework/views/.gitignore | 2 - .../fixtures/laravel/storage/logs/.gitignore | 2 - tests/fixtures/stubs/model.stub | 1 - .../invalid/without-name/composer.json | 20 - .../invalid/without-namespace/composer.json | 10 - .../valid/article-category/composer.json | 29 - .../ArticleCategoryServiceProvider.php | 89 --- .../src/Providers/RouteServiceProvider.php | 57 -- .../modules/valid/article-copy/composer.json | 29 - .../src/Providers/ArticleServiceProvider.php | 89 --- .../src/Providers/RouteServiceProvider.php | 57 -- .../stubs/modules/valid/article/composer.json | 29 - .../src/Providers/ArticleServiceProvider.php | 89 --- .../src/Providers/RouteServiceProvider.php | 57 -- .../stubs/modules/valid/author/composer.json | 31 -- .../valid/author/src/Facades/SomeFacade.php | 7 - .../src/Providers/AuthorServiceProvider.php | 89 --- .../src/Providers/RouteServiceProvider.php | 59 -- .../modules/valid/empty-module/composer.json | 15 - .../stubs/modules/valid/empty/composer.json | 15 - .../modules/valid/navigation/composer.json | 29 - .../Providers/GeoLocationServiceProvider.php | 89 --- .../src/Providers/RouteServiceProvider.php | 57 -- 408 files changed, 21410 deletions(-) delete mode 100644 .php-cs-fixer.dist.php delete mode 100755 config/config.php delete mode 100644 phpstan-baseline.neon delete mode 100644 phpunit.xml delete mode 100644 src/Commands/BaseCommand.php delete mode 100644 src/Commands/BaseMigrationCommand.php delete mode 100644 src/Commands/CacheClearCommand.php delete mode 100644 src/Commands/CacheCommand.php delete mode 100644 src/Commands/Generators/ActionMakeCommand.php delete mode 100755 src/Commands/Generators/BaseComponentGeneratorCommand.php delete mode 100644 src/Commands/Generators/CommandMakeCommand.php delete mode 100644 src/Commands/Generators/ControllerMakeCommand.php delete mode 100644 src/Commands/Generators/DTOMakeCommand.php delete mode 100644 src/Commands/Generators/EventMakeCommand.php delete mode 100644 src/Commands/Generators/ExceptionMakeCommand.php delete mode 100644 src/Commands/Generators/FactoryMakeCommand.php delete mode 100644 src/Commands/Generators/JobMakeCommand.php delete mode 100644 src/Commands/Generators/ListenerMakeCommand.php delete mode 100644 src/Commands/Generators/MailMakeCommand.php delete mode 100644 src/Commands/Generators/MiddlewareMakeCommand.php delete mode 100644 src/Commands/Generators/MigrationMakeCommand.php delete mode 100644 src/Commands/Generators/ModelMakeCommand.php delete mode 100755 src/Commands/Generators/ModuleMakeCommand.php delete mode 100644 src/Commands/Generators/NotificationMakeCommand.php delete mode 100644 src/Commands/Generators/ObserverMakeCommand.php delete mode 100644 src/Commands/Generators/PolicyMakeCommand.php delete mode 100644 src/Commands/Generators/ProviderMakeCommand.php delete mode 100644 src/Commands/Generators/QueryWizardMakeCommand.php delete mode 100644 src/Commands/Generators/RequestMakeCommand.php delete mode 100644 src/Commands/Generators/ResourceMakeCommand.php delete mode 100644 src/Commands/Generators/RouteMakeCommand.php delete mode 100644 src/Commands/Generators/RuleMakeCommand.php delete mode 100644 src/Commands/Generators/SeederMakeCommand.php delete mode 100644 src/Commands/Generators/TestMakeCommand.php delete mode 100644 src/Commands/Generators/stubs/action/create.stub delete mode 100644 src/Commands/Generators/stubs/action/delete.stub delete mode 100644 src/Commands/Generators/stubs/action/list.stub delete mode 100644 src/Commands/Generators/stubs/action/plain.stub delete mode 100644 src/Commands/Generators/stubs/action/update.stub delete mode 100644 src/Commands/Generators/stubs/action/view.stub delete mode 100755 src/Commands/Generators/stubs/command.stub delete mode 100644 src/Commands/Generators/stubs/composer.json.stub delete mode 100644 src/Commands/Generators/stubs/controller/api.stub delete mode 100644 src/Commands/Generators/stubs/controller/web.stub delete mode 100644 src/Commands/Generators/stubs/dto.stub delete mode 100755 src/Commands/Generators/stubs/event.stub delete mode 100644 src/Commands/Generators/stubs/exception.stub delete mode 100755 src/Commands/Generators/stubs/factory.stub delete mode 100644 src/Commands/Generators/stubs/job/plain.stub delete mode 100644 src/Commands/Generators/stubs/job/queued.stub delete mode 100755 src/Commands/Generators/stubs/listener/plain.stub delete mode 100755 src/Commands/Generators/stubs/listener/queued.stub delete mode 100644 src/Commands/Generators/stubs/mail.stub delete mode 100755 src/Commands/Generators/stubs/middleware.stub delete mode 100755 src/Commands/Generators/stubs/migration/add.stub delete mode 100755 src/Commands/Generators/stubs/migration/create.stub delete mode 100755 src/Commands/Generators/stubs/migration/delete.stub delete mode 100644 src/Commands/Generators/stubs/migration/pivot.stub delete mode 100755 src/Commands/Generators/stubs/migration/plain.stub delete mode 100644 src/Commands/Generators/stubs/model/full.stub delete mode 100644 src/Commands/Generators/stubs/model/plain.stub delete mode 100644 src/Commands/Generators/stubs/notification/plain.stub delete mode 100644 src/Commands/Generators/stubs/notification/queued.stub delete mode 100644 src/Commands/Generators/stubs/observer.stub delete mode 100644 src/Commands/Generators/stubs/policy/full.stub delete mode 100644 src/Commands/Generators/stubs/policy/plain.stub delete mode 100644 src/Commands/Generators/stubs/provider/event.stub delete mode 100644 src/Commands/Generators/stubs/provider/module.stub delete mode 100644 src/Commands/Generators/stubs/provider/plain.stub delete mode 100644 src/Commands/Generators/stubs/provider/route.stub delete mode 100644 src/Commands/Generators/stubs/query-wizard/eloquent.stub delete mode 100644 src/Commands/Generators/stubs/query-wizard/model.stub delete mode 100644 src/Commands/Generators/stubs/request/create.stub delete mode 100644 src/Commands/Generators/stubs/request/delete.stub delete mode 100644 src/Commands/Generators/stubs/request/list.stub delete mode 100644 src/Commands/Generators/stubs/request/plain.stub delete mode 100644 src/Commands/Generators/stubs/request/update.stub delete mode 100644 src/Commands/Generators/stubs/request/view.stub delete mode 100644 src/Commands/Generators/stubs/resource/collection.stub delete mode 100644 src/Commands/Generators/stubs/resource/single.stub delete mode 100755 src/Commands/Generators/stubs/route.stub delete mode 100755 src/Commands/Generators/stubs/rule.stub delete mode 100644 src/Commands/Generators/stubs/seeder/permissions.stub delete mode 100644 src/Commands/Generators/stubs/seeder/plain.stub delete mode 100644 src/Commands/Generators/stubs/test/api/create.stub delete mode 100644 src/Commands/Generators/stubs/test/api/delete.stub delete mode 100644 src/Commands/Generators/stubs/test/api/list.stub delete mode 100644 src/Commands/Generators/stubs/test/api/plain.stub delete mode 100644 src/Commands/Generators/stubs/test/api/update.stub delete mode 100644 src/Commands/Generators/stubs/test/api/view.stub delete mode 100644 src/Commands/Generators/stubs/test/cli/plain.stub delete mode 100644 src/Commands/Generators/stubs/test/feature/plain.stub delete mode 100644 src/Commands/Generators/stubs/test/unit/plain.stub delete mode 100644 src/Commands/Generators/stubs/test/web/create.stub delete mode 100644 src/Commands/Generators/stubs/test/web/delete.stub delete mode 100644 src/Commands/Generators/stubs/test/web/plain.stub delete mode 100644 src/Commands/Generators/stubs/test/web/update.stub delete mode 100644 src/Commands/ListCommand.php delete mode 100644 src/Commands/MigrateCommand.php delete mode 100644 src/Commands/MigrateRefreshCommand.php delete mode 100644 src/Commands/MigrateResetCommand.php delete mode 100644 src/Commands/MigrateRollbackCommand.php delete mode 100644 src/Commands/MigrateStatusCommand.php delete mode 100644 src/Commands/ModuleDeleteCommand.php delete mode 100644 src/Commands/StubPublishCommand.php delete mode 100644 src/Commands/SyncCommand.php delete mode 100644 src/Enums/ModuleComponentType.php delete mode 100644 src/Exceptions/CannotDeleteModule.php delete mode 100644 src/Exceptions/ComposerException.php delete mode 100644 src/Exceptions/DirectoryMustBePresentAndWritable.php delete mode 100644 src/Exceptions/InvalidClassName.php delete mode 100644 src/Exceptions/InvalidConfigValue.php delete mode 100644 src/Exceptions/InvalidTableName.php delete mode 100644 src/Exceptions/ModuleHasNoNamespace.php delete mode 100644 src/Exceptions/ModuleHasNonUniquePackageName.php delete mode 100755 src/Exceptions/ModuleNotFound.php delete mode 100644 src/Exceptions/NameIsReserved.php delete mode 100755 src/Module.php delete mode 100644 src/ModulesRepository.php delete mode 100644 src/Providers/ComposerServiceProvider.php delete mode 100644 src/Providers/ConsoleServiceProvider.php delete mode 100644 src/Providers/ModulesRepositoryServiceProvider.php delete mode 100755 src/Providers/ModulesServiceProvider.php delete mode 100644 src/Support/Composer.php delete mode 100644 src/Support/ComposerJsonFile.php delete mode 100644 src/Support/Concerns/CanLoadRoutesFromDirectory.php delete mode 100644 src/Support/Concerns/CanRunModuleSeeders.php delete mode 100644 src/Support/Concerns/InteractsWithTestUser.php delete mode 100644 src/Support/Concerns/WithJsonResponseHelpers.php delete mode 100755 src/Support/Facades/Modules.php delete mode 100755 src/Support/Generator/GeneratorHelper.php delete mode 100755 src/Support/Generator/GeneratorPath.php delete mode 100755 src/Support/Generator/Stub.php delete mode 100755 src/Support/Migrations/NameParser.php delete mode 100755 src/Support/Migrations/SchemaParser.php delete mode 100644 src/Support/ModuleConfigWriter.php delete mode 100644 src/Support/ModuleServiceProvider.php delete mode 100644 tests/Commands/BaseCommandTest.php delete mode 100644 tests/Commands/CacheClearCommandTest.php delete mode 100644 tests/Commands/CacheCommandTest.php delete mode 100644 tests/Commands/Generators/ActionMakeCommandTest.php delete mode 100644 tests/Commands/Generators/BaseComponentGeneratorCommandTest.php delete mode 100644 tests/Commands/Generators/CommandMakeCommandTest.php delete mode 100644 tests/Commands/Generators/ControllerMakeCommandTest.php delete mode 100644 tests/Commands/Generators/DTOMakeCommandTest.php delete mode 100644 tests/Commands/Generators/EventMakeCommandTest.php delete mode 100644 tests/Commands/Generators/ExceptionMakeCommandTest.php delete mode 100644 tests/Commands/Generators/FactoryMakeCommandTest.php delete mode 100644 tests/Commands/Generators/JobMakeCommandTest.php delete mode 100644 tests/Commands/Generators/ListenerMakeCommandTest.php delete mode 100644 tests/Commands/Generators/MailMakeCommandTest.php delete mode 100644 tests/Commands/Generators/MiddlewareMakeCommandTest.php delete mode 100644 tests/Commands/Generators/MigrationMakeCommandTest.php delete mode 100644 tests/Commands/Generators/ModelMakeCommandTest.php delete mode 100644 tests/Commands/Generators/ModuleMakeCommandTest.php delete mode 100644 tests/Commands/Generators/NotificationMakeCommandTest.php delete mode 100644 tests/Commands/Generators/ObserverMakeCommandTest.php delete mode 100644 tests/Commands/Generators/PolicyMakeCommandTest.php delete mode 100644 tests/Commands/Generators/ProviderMakeCommandTest.php delete mode 100644 tests/Commands/Generators/QueryWizardMakeCommandTest.php delete mode 100644 tests/Commands/Generators/RequestMakeCommandTest.php delete mode 100644 tests/Commands/Generators/ResourceMakeCommandTest.php delete mode 100644 tests/Commands/Generators/RouteMakeCommandTest.php delete mode 100644 tests/Commands/Generators/RuleMakeCommandTest.php delete mode 100644 tests/Commands/Generators/SeederMakeCommandTest.php delete mode 100644 tests/Commands/Generators/TestMakeCommandTest.php delete mode 100644 tests/Commands/ListCommandTest.php delete mode 100644 tests/Commands/MigrateCommandTest.php delete mode 100644 tests/Commands/MigrateRefreshCommandTest.php delete mode 100644 tests/Commands/MigrateResetCommandTest.php delete mode 100644 tests/Commands/MigrateRollbackCommandTest.php delete mode 100644 tests/Commands/MigrateStatusCommandTest.php delete mode 100644 tests/Commands/ModuleDeleteCommandTest.php delete mode 100644 tests/Commands/StubPublishCommandTest.php delete mode 100644 tests/Commands/SyncCommandTest.php delete mode 100644 tests/ModuleTest.php delete mode 100644 tests/ModulesRepositoryTest.php delete mode 100644 tests/Pest.php delete mode 100644 tests/Support/ComposerJsonFileTest.php delete mode 100644 tests/Support/ComposerTest.php delete mode 100644 tests/Support/Concerns/CanLoadRoutesFromDirectoryTest.php delete mode 100644 tests/Support/Concerns/CanRunModuleSeedersTest.php delete mode 100644 tests/Support/Generator/GeneratorHelperTest.php delete mode 100644 tests/TestCase.php delete mode 100644 tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__api__module__2.yml delete mode 100644 tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__base__module__2.yml delete mode 100644 tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__plain__module__2.yml delete mode 100644 tests/__snapshots__/StubPublishCommandTest__it_publish_all_laraneat_modules_stubs__1.yml delete mode 100644 tests/__snapshots__/files/ActionMakeCommandTest__it_generates__create__action_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ActionMakeCommandTest__it_generates__delete__action_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ActionMakeCommandTest__it_generates__list__action_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ActionMakeCommandTest__it_generates__plain__action_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ActionMakeCommandTest__it_generates__update__action_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ActionMakeCommandTest__it_generates__view__action_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/CommandMakeCommandTest__it_generates_console_command_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ControllerMakeCommandTest__it_generates__api__controller_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ControllerMakeCommandTest__it_generates__web__controller_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/DTOMakeCommandTest__it_generates_dto_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/EventMakeCommandTest__it_generates_event_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ExceptionMakeCommandTest__it_generates_exception_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/FactoryMakeCommandTest__it_generates_factory_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/JobMakeCommandTest__it_generates__plain__job_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/JobMakeCommandTest__it_generates__queued__job_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ListenerMakeCommandTest__it_generates__plain__listener_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ListenerMakeCommandTest__it_generates__queued__listener_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/MailMakeCommandTest__it_generates_mail_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/MiddlewareMakeCommandTest__it_generates_middleware_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/MigrationMakeCommandTest__it_automatically_detects_and_generates__add__migration_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/MigrationMakeCommandTest__it_automatically_detects_and_generates__create__migration_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/MigrationMakeCommandTest__it_automatically_detects_and_generates__delete__migration_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__add__migration_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__add__migration_for_the_module_without_fields__1.php delete mode 100644 tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__create__migration_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__create__migration_for_the_module_without_fields__1.php delete mode 100644 tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__delete__migration_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__delete__migration_for_the_module_without_fields__1.php delete mode 100644 tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__pivot__migration_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__plain__migration_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__plain__migration_for_the_module_if_the_stub_is_not_recognized_by_name__1.php delete mode 100644 tests/__snapshots__/files/ModelMakeCommandTest__it_generates_model_with_factory_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ModelMakeCommandTest__it_generates_plain_model_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_displays_an_error_message_if_the_passed_module_name_is_not_valid__1.json delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_displays_an_error_message_when_a_module_with_the_same_folder_name_already_exists__1.json delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_displays_an_error_message_when_a_module_with_the_same_package_name_already_exists__1.json delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__1.json delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__10.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__11.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__12.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__13.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__14.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__15.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__16.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__17.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__18.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__19.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__20.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__21.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__22.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__23.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__24.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__25.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__26.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__27.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__28.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__29.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__3.json delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__30.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__31.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__32.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__33.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__34.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__35.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__4.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__5.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__6.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__7.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__8.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__9.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__1.json delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__10.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__3.json delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__4.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__5.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__6.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__7.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__8.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__9.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__1.json delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__3.json delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__4.php delete mode 100644 tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__5.php delete mode 100644 tests/__snapshots__/files/ModuleTest__it_can_add_aliases__1.json delete mode 100644 tests/__snapshots__/files/ModuleTest__it_can_add_aliases__2.json delete mode 100644 tests/__snapshots__/files/ModuleTest__it_can_add_aliases_via_ModuleConfigWriter__1.json delete mode 100644 tests/__snapshots__/files/ModuleTest__it_can_add_aliases_via_ModuleConfigWriter__2.json delete mode 100644 tests/__snapshots__/files/ModuleTest__it_can_add_providers__1.json delete mode 100644 tests/__snapshots__/files/ModuleTest__it_can_add_providers__2.json delete mode 100644 tests/__snapshots__/files/ModuleTest__it_can_add_providers_via_ModuleConfigWriter__1.json delete mode 100644 tests/__snapshots__/files/ModuleTest__it_can_add_providers_via_ModuleConfigWriter__2.json delete mode 100644 tests/__snapshots__/files/ModuleTest__it_can_set_aliases__1.json delete mode 100644 tests/__snapshots__/files/ModuleTest__it_can_set_aliases__2.json delete mode 100644 tests/__snapshots__/files/ModuleTest__it_can_set_providers__1.json delete mode 100644 tests/__snapshots__/files/ModuleTest__it_can_set_providers__2.json delete mode 100644 tests/__snapshots__/files/ModuleTest__it_can_update_aliases_via_ModuleConfigWriter__1.json delete mode 100644 tests/__snapshots__/files/ModuleTest__it_can_update_aliases_via_ModuleConfigWriter__2.json delete mode 100644 tests/__snapshots__/files/ModuleTest__it_can_update_providers_via_ModuleConfigWriter__1.json delete mode 100644 tests/__snapshots__/files/ModuleTest__it_can_update_providers_via_ModuleConfigWriter__2.json delete mode 100644 tests/__snapshots__/files/ModulesRepositoryTest__it_can_sync_modules_with_composer__1.json delete mode 100644 tests/__snapshots__/files/ModulesRepositoryTest__it_can_sync_modules_with_composer__2.json delete mode 100644 tests/__snapshots__/files/NotificationMakeCommandTest__it_generates__plain__notification_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/NotificationMakeCommandTest__it_generates__queued__notification_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ObserverMakeCommandTest__it_generates_observer_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/PolicyMakeCommandTest__it_generates_full_policy_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/PolicyMakeCommandTest__it_generates_plain_policy_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ProviderMakeCommandTest__it_generates__event__provider_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ProviderMakeCommandTest__it_generates__module__provider_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ProviderMakeCommandTest__it_generates__plain__provider_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ProviderMakeCommandTest__it_generates__route__provider_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/QueryWizardMakeCommandTest__it_generates__eloquent__query_wizard_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/QueryWizardMakeCommandTest__it_generates__model__query_wizard_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RequestMakeCommandTest__it_generates__create__api_request_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RequestMakeCommandTest__it_generates__create__web_request_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RequestMakeCommandTest__it_generates__delete__api_request_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RequestMakeCommandTest__it_generates__delete__web_request_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RequestMakeCommandTest__it_generates__list__api_request_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RequestMakeCommandTest__it_generates__plain__api_request_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RequestMakeCommandTest__it_generates__plain__web_request_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RequestMakeCommandTest__it_generates__update__api_request_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RequestMakeCommandTest__it_generates__update__web_request_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RequestMakeCommandTest__it_generates__view__api_request_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ResourceMakeCommandTest__it_generates__collection__resource_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/ResourceMakeCommandTest__it_generates__single__resource_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RouteMakeCommandTest__it_generates__delete__api_route_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RouteMakeCommandTest__it_generates__delete__web_route_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RouteMakeCommandTest__it_generates__get__api_route_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RouteMakeCommandTest__it_generates__get__web_route_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RouteMakeCommandTest__it_generates__options__api_route_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RouteMakeCommandTest__it_generates__options__web_route_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RouteMakeCommandTest__it_generates__patch__api_route_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RouteMakeCommandTest__it_generates__patch__web_route_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RouteMakeCommandTest__it_generates__post__api_route_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RouteMakeCommandTest__it_generates__post__web_route_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RouteMakeCommandTest__it_generates__put__api_route_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RouteMakeCommandTest__it_generates__put__web_route_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/RuleMakeCommandTest__it_generates_rule_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/SeederMakeCommandTest__it_generates__permissions__seeder_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/SeederMakeCommandTest__it_generates__plain__seeder_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/TestMakeCommandTest__it_generates__unit__test_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/TestMakeCommandTest__it_generates_create__api__test_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/TestMakeCommandTest__it_generates_create__web__test_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/TestMakeCommandTest__it_generates_delete__api__test_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/TestMakeCommandTest__it_generates_delete__web__test_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/TestMakeCommandTest__it_generates_list__api__test_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/TestMakeCommandTest__it_generates_plain__api__test_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/TestMakeCommandTest__it_generates_plain__cli__test_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/TestMakeCommandTest__it_generates_plain__feature__test_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/TestMakeCommandTest__it_generates_plain__web__test_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/TestMakeCommandTest__it_generates_update__api__test_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/TestMakeCommandTest__it_generates_update__web__test_for_the_module__1.php delete mode 100644 tests/__snapshots__/files/TestMakeCommandTest__it_generates_view__api__test_for_the_module__1.php delete mode 100644 tests/fixtures/laravel/.gitignore delete mode 100644 tests/fixtures/laravel/app/.gitkeep delete mode 100644 tests/fixtures/laravel/bootstrap/app.php delete mode 100644 tests/fixtures/laravel/bootstrap/cache/.gitignore delete mode 100644 tests/fixtures/laravel/composer.json delete mode 100644 tests/fixtures/laravel/config/app.php delete mode 100644 tests/fixtures/laravel/config/auth.php delete mode 100644 tests/fixtures/laravel/config/broadcasting.php delete mode 100644 tests/fixtures/laravel/config/cache.php delete mode 100644 tests/fixtures/laravel/config/cors.php delete mode 100644 tests/fixtures/laravel/config/database.php delete mode 100644 tests/fixtures/laravel/config/filesystems.php delete mode 100644 tests/fixtures/laravel/config/hashing.php delete mode 100644 tests/fixtures/laravel/config/logging.php delete mode 100644 tests/fixtures/laravel/config/mail.php delete mode 100644 tests/fixtures/laravel/config/queue.php delete mode 100644 tests/fixtures/laravel/config/sanctum.php delete mode 100644 tests/fixtures/laravel/config/services.php delete mode 100644 tests/fixtures/laravel/config/session.php delete mode 100644 tests/fixtures/laravel/config/view.php delete mode 100644 tests/fixtures/laravel/database/.gitignore delete mode 100644 tests/fixtures/laravel/database/factories/.gitkeep delete mode 100644 tests/fixtures/laravel/database/migrations/.gitkeep delete mode 100644 tests/fixtures/laravel/database/seeders/.gitkeep delete mode 100644 tests/fixtures/laravel/migrations/2014_10_12_000000_testbench_create_users_table.php delete mode 100644 tests/fixtures/laravel/migrations/2014_10_12_100000_testbench_create_password_resets_table.php delete mode 100644 tests/fixtures/laravel/migrations/2019_08_19_000000_testbench_create_failed_jobs_table.php delete mode 100644 tests/fixtures/laravel/public/.gitignore delete mode 100644 tests/fixtures/laravel/public/index.php delete mode 100644 tests/fixtures/laravel/resources/lang/en/auth.php delete mode 100644 tests/fixtures/laravel/resources/lang/en/pagination.php delete mode 100644 tests/fixtures/laravel/resources/lang/en/passwords.php delete mode 100644 tests/fixtures/laravel/resources/lang/en/validation.php delete mode 100644 tests/fixtures/laravel/resources/views/errors/503.blade.php delete mode 100644 tests/fixtures/laravel/resources/views/welcome.blade.php delete mode 100644 tests/fixtures/laravel/routes/.gitignore delete mode 100644 tests/fixtures/laravel/server.php delete mode 100644 tests/fixtures/laravel/storage/app/.gitignore delete mode 100644 tests/fixtures/laravel/storage/app/modules/modules.used delete mode 100644 tests/fixtures/laravel/storage/app/public/.gitignore delete mode 100644 tests/fixtures/laravel/storage/framework/.gitignore delete mode 100644 tests/fixtures/laravel/storage/framework/cache/.gitignore delete mode 100644 tests/fixtures/laravel/storage/framework/data/.gitignore delete mode 100644 tests/fixtures/laravel/storage/framework/sessions/.gitignore delete mode 100644 tests/fixtures/laravel/storage/framework/testing/.gitignore delete mode 100644 tests/fixtures/laravel/storage/framework/views/.gitignore delete mode 100644 tests/fixtures/laravel/storage/logs/.gitignore delete mode 100644 tests/fixtures/stubs/model.stub delete mode 100644 tests/fixtures/stubs/modules/invalid/without-name/composer.json delete mode 100644 tests/fixtures/stubs/modules/invalid/without-namespace/composer.json delete mode 100644 tests/fixtures/stubs/modules/valid/article-category/composer.json delete mode 100644 tests/fixtures/stubs/modules/valid/article-category/src/Providers/ArticleCategoryServiceProvider.php delete mode 100644 tests/fixtures/stubs/modules/valid/article-category/src/Providers/RouteServiceProvider.php delete mode 100644 tests/fixtures/stubs/modules/valid/article-copy/composer.json delete mode 100644 tests/fixtures/stubs/modules/valid/article-copy/src/Providers/ArticleServiceProvider.php delete mode 100644 tests/fixtures/stubs/modules/valid/article-copy/src/Providers/RouteServiceProvider.php delete mode 100644 tests/fixtures/stubs/modules/valid/article/composer.json delete mode 100644 tests/fixtures/stubs/modules/valid/article/src/Providers/ArticleServiceProvider.php delete mode 100644 tests/fixtures/stubs/modules/valid/article/src/Providers/RouteServiceProvider.php delete mode 100644 tests/fixtures/stubs/modules/valid/author/composer.json delete mode 100644 tests/fixtures/stubs/modules/valid/author/src/Facades/SomeFacade.php delete mode 100644 tests/fixtures/stubs/modules/valid/author/src/Providers/AuthorServiceProvider.php delete mode 100644 tests/fixtures/stubs/modules/valid/author/src/Providers/RouteServiceProvider.php delete mode 100644 tests/fixtures/stubs/modules/valid/empty-module/composer.json delete mode 100644 tests/fixtures/stubs/modules/valid/empty/composer.json delete mode 100644 tests/fixtures/stubs/modules/valid/navigation/composer.json delete mode 100644 tests/fixtures/stubs/modules/valid/navigation/src/Providers/GeoLocationServiceProvider.php delete mode 100644 tests/fixtures/stubs/modules/valid/navigation/src/Providers/RouteServiceProvider.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php deleted file mode 100644 index 3012836..0000000 --- a/.php-cs-fixer.dist.php +++ /dev/null @@ -1,39 +0,0 @@ -in([ - __DIR__ . '/src', - __DIR__ . '/tests', - ]) - ->name('*.php') - ->notName('*.blade.php') - ->exclude([ - '__snapshots__', - '.pest', - ]) - ->ignoreDotFiles(true) - ->ignoreVCS(true); - -return (new PhpCsFixer\Config()) - ->setRules([ - '@PSR12' => true, - 'array_syntax' => ['syntax' => 'short'], - 'ordered_imports' => ['sort_algorithm' => 'alpha'], - 'no_unused_imports' => true, - 'not_operator_with_successor_space' => true, - 'trailing_comma_in_multiline' => true, - 'phpdoc_scalar' => true, - 'unary_operator_spaces' => true, - 'binary_operator_spaces' => true, - 'blank_line_before_statement' => [ - 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], - ], - 'phpdoc_single_line_var_spacing' => true, - 'phpdoc_var_without_name' => true, - 'method_argument_space' => [ - 'on_multiline' => 'ensure_fully_multiline', - 'keep_multiple_spaces_after_comma' => true, - ], - 'single_trait_insert_per_statement' => true, - ]) - ->setFinder($finder); diff --git a/config/config.php b/config/config.php deleted file mode 100755 index 7848b1c..0000000 --- a/config/config.php +++ /dev/null @@ -1,229 +0,0 @@ - base_path('modules'), - - /* - |-------------------------------------------------------------------------- - | Module namespace prefix - |-------------------------------------------------------------------------- - | - | Prefix for the namespace of the generated modules - | - */ - 'namespace' => 'Modules', - - /* - |-------------------------------------------------------------------------- - | Custom generator stubs path - |-------------------------------------------------------------------------- - | - | Place your custom stubs in this folder - | - */ - 'custom_stubs' => base_path('stubs/modules'), - - /* - |-------------------------------------------------------------------------- - | Composer File Template - |-------------------------------------------------------------------------- - | - | Configuration composer.json of generated modules - | - */ - 'composer' => [ - 'vendor' => 'app', - 'author' => [ - 'name' => 'Example', - 'email' => 'example@example.com' - ], - ], - - /* - |-------------------------------------------------------------------------- - | User model class - |-------------------------------------------------------------------------- - | - | Customize the User model of the application - | - */ - 'user_model' => null, - - /* - |-------------------------------------------------------------------------- - | "Create permission" classes - |-------------------------------------------------------------------------- - | - | Customize "create permission" classes - | - */ - 'create_permission' => [ - 'action' => null, - 'dto' => null - ], - - /* - |-------------------------------------------------------------------------- - | Component paths - |-------------------------------------------------------------------------- - | - | Customize the paths where the folders will be generated. - | - */ - 'components' => [ - ModuleComponentType::Action->value => [ - 'path' => 'src/Actions', - 'namespace' => 'Actions' - ], - ModuleComponentType::ApiController->value => [ - 'path' => 'src/UI/API/Controllers', - 'namespace' => 'UI\\API\\Controllers' - ], - ModuleComponentType::ApiQueryWizard->value => [ - 'path' => 'src/UI/API/QueryWizards', - 'namespace' => 'UI\\API\\QueryWizards' - ], - ModuleComponentType::ApiRequest->value => [ - 'path' => 'src/UI/API/Requests', - 'namespace' => 'UI\\API\\Requests' - ], - ModuleComponentType::ApiResource->value => [ - 'path' => 'src/UI/API/Resources', - 'namespace' => 'UI\\API\\Resources' - ], - ModuleComponentType::ApiRoute->value => [ - 'path' => 'src/UI/API/routes', - 'namespace' => 'UI\\API\\Routes' - ], - ModuleComponentType::ApiTest->value => [ - 'path' => 'tests/UI/API', - 'namespace' => 'Tests\\UI\\API' - ], - ModuleComponentType::CliCommand->value => [ - 'path' => 'src/UI/CLI/Commands', - 'namespace' => 'UI\\CLI\\Commands' - ], - ModuleComponentType::CliTest->value => [ - 'path' => 'tests/UI/CLI', - 'namespace' => 'Tests\\UI\\CLI' - ], - ModuleComponentType::Config->value => [ - 'path' => 'config' - ], - ModuleComponentType::Dto->value => [ - 'path' => 'src/DTO', - 'namespace' => 'DTO' - ], - ModuleComponentType::Event->value => [ - 'path' => 'src/Events', - 'namespace' => 'Events' - ], - ModuleComponentType::Exception->value => [ - 'path' => 'src/Exceptions', - 'namespace' => 'Exceptions' - ], - ModuleComponentType::Factory->value => [ - 'path' => 'database/factories', - 'namespace' => 'Database\\Factories' - ], - ModuleComponentType::FeatureTest->value => [ - 'path' => 'tests/Feature', - 'namespace' => 'Tests\\Feature' - ], - ModuleComponentType::Job->value => [ - 'path' => 'src/Jobs', - 'namespace' => 'Jobs' - ], - ModuleComponentType::Lang->value => [ - 'path' => 'lang' - ], - ModuleComponentType::Listener->value => [ - 'path' => 'src/Listeners', - 'namespace' => 'Listeners' - ], - ModuleComponentType::Mail->value => [ - 'path' => 'src/Mails', - 'namespace' => 'Mails' - ], - ModuleComponentType::Middleware->value => [ - 'path' => 'src/Middleware', - 'namespace' => 'Middleware' - ], - ModuleComponentType::Migration->value => [ - 'path' => 'database/migrations' - ], - ModuleComponentType::Model->value => [ - 'path' => 'src/Models', - 'namespace' => 'Models' - ], - ModuleComponentType::Notification->value => [ - 'path' => 'src/Notifications', - 'namespace' => 'Notifications' - ], - ModuleComponentType::Observer->value => [ - 'path' => 'src/Observers', - 'namespace' => 'Observers' - ], - ModuleComponentType::Policy->value => [ - 'path' => 'src/Policies', - 'namespace' => 'Policies' - ], - ModuleComponentType::Provider->value => [ - 'path' => 'src/Providers', - 'namespace' => 'Providers' - ], - ModuleComponentType::Rule->value => [ - 'path' => 'src/Rules', - 'namespace' => 'Rules' - ], - ModuleComponentType::Seeder->value => [ - 'path' => 'database/seeders', - 'namespace' => 'Database\\Seeders' - ], - ModuleComponentType::UnitTest->value => [ - 'path' => 'tests/Unit', - 'namespace' => 'Tests\\Unit' - ], - ModuleComponentType::View->value => [ - 'path' => 'resources/views' - ], - ModuleComponentType::WebController->value => [ - 'path' => 'src/UI/WEB/Controllers', - 'namespace' => 'UI\\WEB\\Controllers' - ], - ModuleComponentType::WebRequest->value => [ - 'path' => 'src/UI/WEB/Requests', - 'namespace' => 'UI\\WEB\\Requests' - ], - ModuleComponentType::WebRoute->value => [ - 'path' => 'src/UI/WEB/routes', - 'namespace' => 'UI\\WEB\\Routes' - ], - ModuleComponentType::WebTest->value => [ - 'path' => 'tests/UI/WEB', - 'namespace' => 'Tests\\UI\\WEB' - ], - ], - - /* - |-------------------------------------------------------------------------- - | Caching - |-------------------------------------------------------------------------- - | - | Here is the config for setting up caching feature for scanned modules. - | - */ - 'cache' => [ - 'enabled' => env('APP_ENV', 'production') === 'production', - ], -]; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon deleted file mode 100644 index 4a797fd..0000000 --- a/phpstan-baseline.neon +++ /dev/null @@ -1,3 +0,0 @@ -parameters: - ignoreErrors: - - "#Unsafe usage of new static#" diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index 417198b..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - ./tests - - - - - ./src - - - diff --git a/src/Commands/BaseCommand.php b/src/Commands/BaseCommand.php deleted file mode 100644 index 5bb23b9..0000000 --- a/src/Commands/BaseCommand.php +++ /dev/null @@ -1,187 +0,0 @@ -|Module - * - * @throws ModuleNotFound - * @throws ModuleHasNonUniquePackageName - * @throws ModuleHasNoNamespace - */ - protected function getModuleArgumentOrFail(): array|Module - { - $allPackageNames = array_keys($this->modulesRepository->getModules()); - $moduleArgument = $this->input->getArgument('module'); - $multipleModuleMode = is_array($moduleArgument); - - if ($multipleModuleMode) { - $moduleArgument = array_values(array_unique($moduleArgument)); - - if (empty($moduleArgument)) { - $moduleArgument = multiselect( - label: 'Select one or more module', - options: [ - 'all' => 'All modules', - ...array_combine($allPackageNames, $allPackageNames), - ], - required: 'You must select at least one module', - ); - } - - if (in_array('all', $moduleArgument, true)) { - $moduleArgument = $allPackageNames; - } - } else { - if (empty($moduleArgument)) { - $moduleArgument = select( - label: 'Select one module', - options: $allPackageNames, - required: 'You must select a module', - ); - } - } - - $this->input->setArgument('module', value: $moduleArgument); - - if (! $multipleModuleMode) { - return $this->findModuleByNameOrPackageNameOrFail($moduleArgument); - } - - return collect($moduleArgument) - ->map(fn (string $moduleNameOrPackageName) - => $this->findModuleByNameOrPackageNameOrFail($moduleNameOrPackageName)) - ->unique(fn (Module $module) => $module->getPackageName()) - ->values() - ->all(); - } - - /** - * @throws ModuleNotFound - * @throws ModuleHasNonUniquePackageName - * @throws ModuleHasNoNamespace - */ - protected function findModuleByNameOrPackageNameOrFail($moduleNameOrPackageName): Module - { - if ($foundModule = $this->modulesRepository->find($moduleNameOrPackageName)) { - return $foundModule; - } - - $foundModules = collect($this->modulesRepository->filterByName($moduleNameOrPackageName)); - $numberOfFoundModules = $foundModules->count(); - - if ($numberOfFoundModules === 0) { - throw ModuleNotFound::makeForNameOrPackageName($moduleNameOrPackageName); - } - - if ($numberOfFoundModules === 1) { - return $foundModules->first(); - } - - $selectedPackageName = $this->choice( - "$numberOfFoundModules modules with name '{$moduleNameOrPackageName}' found, please select one module from those found", - $foundModules->keys()->all(), - ); - - - return $this->modulesRepository->findOrFail($selectedPackageName); - } - - /** - * Checks if the option is set (via CLI), otherwise asks the user for a value - * - * @throws InvalidOptionException - */ - protected function getOptionOrAsk( - string $optionName, - string $question, - ?string $default = null, - bool $required = true - ): ?string { - $value = $this->option($optionName); - - if ($value === '' || $value === null) { - $value = trim($this->ask($question, $default)); - } - - if ($required && $value === '') { - throw new InvalidOptionException( - sprintf("The '%s' option is required", $optionName) - ); - } - - return $value; - } - - /** - * Checks if the option is set (via CLI), otherwise proposes choices to the user - * - * @throws InvalidOptionException - */ - protected function getOptionOrChoice( - string $optionName, - string $question, - array $choices, - ?string $default = null - ): ?string { - $value = $this->option($optionName); - - if ($value === '' || $value === null) { - $value = $this->choice($question, $choices, $default); - } elseif (! in_array($value, $choices, true)) { - throw new InvalidOptionException( - sprintf( - "Wrong '%s' option value provided. Value should be one of '%s'.", - $optionName, - implode("' or '", $choices) - ) - ); - } - - return $value; - } - - /** - * Checks if the option is set (via CLI) - * - * @throws InvalidOptionException - */ - protected function getOptionOneOf( - string $optionName, - array $choices, - ?string $default = null - ): ?string { - $value = $this->option($optionName) ?: $default; - - if (! in_array($value, $choices, true)) { - throw new InvalidOptionException( - sprintf( - "Wrong '%s' option value provided. Value should be one of '%s'.", - $optionName, - implode("' or '", $choices) - ) - ); - } - - return $value; - } -} diff --git a/src/Commands/BaseMigrationCommand.php b/src/Commands/BaseMigrationCommand.php deleted file mode 100644 index 6d5fba3..0000000 --- a/src/Commands/BaseMigrationCommand.php +++ /dev/null @@ -1,71 +0,0 @@ -requiresConfirmation && ! $this->confirmToProceed()) { - return self::FAILURE; - } - - try { - $modulesToHandle = $this->getModuleArgumentOrFail(); - } catch (ModuleNotFound|ModuleHasNonUniquePackageName|ModuleHasNoNamespace $exception) { - $this->error($exception->getMessage()); - - return self::FAILURE; - } - - foreach ($modulesToHandle as $module) { - $this->line('Running for module: ' . $module->getPackageName() . ''); - $this->executeForModule($module); - } - - return self::SUCCESS; - } - - /** - * Execute the migration operation for a single module. - */ - abstract protected function executeForModule(Module $module): void; - - /** - * Get migration paths for a module. - * - * @return array - */ - protected function getMigrationPaths(Module $module): array - { - /** @var Migrator|null $migrator */ - $migrator = $this->laravel['migrator'] ?? null; - - if ($migrator === null) { - return []; - } - - return collect($migrator->paths()) - ->filter(fn (string $path) => Str::startsWith($path, $module->getPath())) - ->values() - ->toArray(); - } -} diff --git a/src/Commands/CacheClearCommand.php b/src/Commands/CacheClearCommand.php deleted file mode 100644 index 641ecff..0000000 --- a/src/Commands/CacheClearCommand.php +++ /dev/null @@ -1,31 +0,0 @@ -modulesRepository->pruneModulesManifest(); - $this->components->info("Modules manifest cache cleared!"); - - return self::SUCCESS; - } -} diff --git a/src/Commands/CacheCommand.php b/src/Commands/CacheCommand.php deleted file mode 100644 index f065224..0000000 --- a/src/Commands/CacheCommand.php +++ /dev/null @@ -1,31 +0,0 @@ -modulesRepository->buildModulesManifest(); - $this->components->info("Modules manifest cached!"); - - return self::SUCCESS; - } -} diff --git a/src/Commands/Generators/ActionMakeCommand.php b/src/Commands/Generators/ActionMakeCommand.php deleted file mode 100644 index 94eb3b6..0000000 --- a/src/Commands/Generators/ActionMakeCommand.php +++ /dev/null @@ -1,144 +0,0 @@ - 'Enter the action class name', - ]; - } - - protected function beforeGenerate(): void - { - $this->ensurePackageIsInstalledOrWarn('lorisleiva/laravel-actions'); - } - - protected function getContents(): string - { - $stub = $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - ['plain', 'create', 'delete', 'list', 'update', 'view'], - 'plain' - ); - - return Stub::create("action/$stub.stub", $this->getStubReplaces($stub))->render(); - } - - /** - * @param string $stub - * @return array - */ - protected function getStubReplaces(string $stub): array - { - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - if ($stub === 'plain') { - return $stubReplaces; - } - - if ($stub === 'create' || $stub === 'update') { - $dtoClass = $this->getFullClassFromOptionOrAsk( - optionName: 'dto', - question: 'Enter the class name of the DTO to be used in the action', - componentType: ModuleComponentType::Dto, - module: $this->module - ); - $stubReplaces['dto'] = class_basename($dtoClass); - $stubReplaces['dtoCamelCase'] = Str::camel($stubReplaces['dto']); - $stubReplaces['dtoNamespace'] = $this->getNamespaceOfClass($dtoClass); - } - - if ($stub !== 'delete') { - $resourceClass = $this->getFullClassFromOptionOrAsk( - optionName: 'resource', - question: 'Enter the class name of the resource to be used in the action', - componentType: ModuleComponentType::ApiResource, - module: $this->module - ); - $stubReplaces['resource'] = class_basename($resourceClass); - $stubReplaces['resourceNamespace'] = $this->getNamespaceOfClass($resourceClass); - - if (in_array($stub, ['list', 'view'], true)) { - $wizardClass = $this->getFullClassFromOptionOrAsk( - optionName: 'wizard', - question: 'Enter the class name of the query wizard to be used in the action', - componentType: ModuleComponentType::ApiQueryWizard, - module: $this->module - ); - $stubReplaces['queryWizard'] = class_basename($wizardClass); - $stubReplaces['queryWizardNamespace'] = $this->getNamespaceOfClass($wizardClass); - } - } - - $modelClass = $this->getFullClassFromOptionOrAsk( - optionName: 'model', - question: 'Enter the class name of the model to be used in the action', - componentType: ModuleComponentType::Model, - module: $this->module - ); - $stubReplaces['model'] = class_basename($modelClass); - $stubReplaces['modelCamelCase'] = Str::camel($stubReplaces['model']); - $stubReplaces['modelNamespace'] = $this->getNamespaceOfClass($modelClass); - - $requestClass = $this->getFullClassFromOptionOrAsk( - optionName: 'request', - question: 'Enter the class name of the request to be used in the action', - componentType: ModuleComponentType::ApiRequest, - module: $this->module - ); - $stubReplaces['request'] = class_basename($requestClass); - $stubReplaces['requestNamespace'] = $this->getNamespaceOfClass($requestClass); - - return $stubReplaces; - } -} diff --git a/src/Commands/Generators/BaseComponentGeneratorCommand.php b/src/Commands/Generators/BaseComponentGeneratorCommand.php deleted file mode 100755 index 845e36a..0000000 --- a/src/Commands/Generators/BaseComponentGeneratorCommand.php +++ /dev/null @@ -1,475 +0,0 @@ -beforeGenerate(); - - try { - $this->nameArgument = $this->argument('name'); - $this->ensureNameIsNotReserved($this->nameArgument); - $this->ensureNameIsValidClassName($this->nameArgument); - $this->module = $this->getModuleArgumentOrFail(); - } catch (NameIsReserved|InvalidClassName|ModuleNotFound|ModuleHasNonUniquePackageName|ModuleHasNoNamespace $exception) { - $this->components->error($exception->getMessage()); - - return self::FAILURE; - } - - try { - $result = $this->generate( - $this->getGeneratedFilePath(), - $this->getContents(), - $this->option('force') - ); - } catch (InvalidTableName $exception) { - $this->components->error($exception->getMessage()); - - return self::FAILURE; - } - - if ($result !== self::SUCCESS) { - return $result; - } - - $this->afterGenerate(); - - return self::SUCCESS; - } - - /** - * Hook called before generation starts. - * Override in subclasses to add custom logic (e.g., package checks). - */ - protected function beforeGenerate(): void - { - // Default: do nothing - } - - /** - * Get the contents for the generated file. - * Must be implemented by subclasses. - */ - abstract protected function getContents(): string; - - /** - * Get the path for the generated file. - * Override in subclasses to customize (e.g., MigrationMakeCommand). - */ - protected function getGeneratedFilePath(): string - { - return $this->getComponentPath($this->module, $this->nameArgument, $this->componentType); - } - - /** - * Hook called after successful generation. - * Override in subclasses to add custom logic (e.g., ProviderMakeCommand). - */ - protected function afterGenerate(): void - { - // Default: do nothing - } - - /** - * @param string $name - * @return void - * - * @throws NameIsReserved - */ - protected function ensureNameIsNotReserved(string $name): void - { - $classBaseName = class_basename($name); - - if ($this->isReservedName($classBaseName)) { - throw NameIsReserved::make($classBaseName); - } - } - - /** - * Validate that the name is a valid PHP class name, optionally prefixed with sub-namespaces. - * Every segment is validated, so names like "../../Foo" can never escape the component directory. - * - * @throws InvalidClassName - */ - protected function ensureNameIsValidClassName(string $name): void - { - foreach (preg_split('#[/\\\\]+#', trim($name, '/\\')) ?: [] as $segment) { - if (! $this->isValidClassName($segment)) { - throw InvalidClassName::make($segment); - } - } - } - - /** - * Check if the name is a valid PHP class name. - */ - protected function isValidClassName(string $name): bool - { - // PHP class names must start with a letter or underscore, - // followed by any number of letters, numbers, or underscores. - // Also supports multibyte characters as per PHP specification. - return (bool) preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $name); - } - - /** - * Check if the name is a valid database table name. - */ - protected function isValidTableName(string $name): bool - { - // Table names should contain only letters, numbers, and underscores, - // and start with a letter or underscore. - return (bool) preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name); - } - - /** - * Generate component file. - * @return int The function returns "0" on success and "1" on failure. - */ - protected function generate(string $path, string $contents, bool $force = false): int - { - if ($force === false && $this->filesystem->exists($path)) { - $this->components->error("File already exists: `$path`. Use --force to overwrite."); - - return self::FAILURE; - } - - $path = str_replace('\\', '/', $path); - $contents = $this->sortImports($contents); - - $directory = dirname($path); - - try { - $this->filesystem->ensureDirectoryExists($directory); - } catch (\Exception $e) { - $this->components->error("Failed to create directory: `$directory`. " . $e->getMessage()); - - return self::FAILURE; - } - - if (! is_writable($directory)) { - $this->components->error("Directory is not writable: `$directory`. Check file permissions."); - - return self::FAILURE; - } - - if ($this->filesystem->put($path, $contents) !== false) { - $this->components->info("Created: `$path`"); - - return self::SUCCESS; - } - - $this->components->error("Failed to write file: `$path`. Check disk space and file permissions."); - - return self::FAILURE; - } - - /** - * Get full class name from option or ask - */ - public function getFullClassFromOptionOrAsk( - string $optionName, - string $question, - ModuleComponentType $componentType, - Module $module - ): string { - return $this->getFullClass( - $this->getOptionOrAsk( - $optionName, - $question, - ), - GeneratorHelper::component($componentType)->getFullNamespace($module) - ); - } - - /** - * Get full class name with namespace - */ - public function getFullClass(string $class, string $defaultNamespace): string - { - if (Str::startsWith($class, '\\')) { - return $class; - } - - return trim($defaultNamespace, '\\') . '\\' . trim($class, '\\'); - } - - /** - * Get the class "namespace" of the given class. - */ - protected function getNamespaceOfClass(string $class): string - { - if (! Str::contains($class, '\\')) { - return ''; - } - - return rtrim(Str::beforeLast($class, '\\'), '\\'); - } - - /** - * Get component namespace, without the class name. - */ - protected function getComponentNamespace(Module $module, string $name, ModuleComponentType $componentType): string - { - $name = str_replace('/', '\\', $name); - $componentNamespace = GeneratorHelper::component($componentType)->getFullNamespace($module); - $subNamespace = $this->getNamespaceOfClass($name); - - $namespace = $componentNamespace . '\\' . $subNamespace; - - return trim($namespace, '\\'); - } - - /** - * Get component path - */ - protected function getComponentPath( - Module $module, - string $name, - ModuleComponentType $componentType, - string $extension = '.php' - ): string { - $componentPath = GeneratorHelper::component($componentType)->getFullPath($module); - $fileName = GeneratorHelper::normalizePath($name); - - return $componentPath . '/' . $fileName . $extension; - } - - protected function getUserModelClass(): string - { - $userModel = GeneratorHelper::getUserModelClass(); - - if (! $userModel) { - $userModel = $this->ask('Enter the class name of the "User model"'); - - if (empty($userModel)) { - throw new LogicException('The "User model" option is required'); - } - } - - return $userModel; - } - - protected function getCreatePermissionActionClass(): string - { - $createPermissionAction = GeneratorHelper::getCreatePermissionActionClass(); - - if (! $createPermissionAction) { - $createPermissionAction = $this->ask('Enter the class name of the "Create permission action"'); - - if (empty($createPermissionAction)) { - throw new LogicException('The "Create permission action" option is required'); - } - } - - return $createPermissionAction; - } - - protected function getCreatePermissionDTOClass(): string - { - $createPermissionAction = GeneratorHelper::getCreatePermissionDTOClass(); - - if (! $createPermissionAction) { - $createPermissionAction = $this->ask('Enter the class name of the "Create permission DTO"'); - - if (empty($createPermissionAction)) { - throw new LogicException('The "Create permission DTO" option is required'); - } - } - - return $createPermissionAction; - } - - /** - * Checks whether the given name is reserved. - */ - protected function isReservedName(string $name): bool - { - $name = strtolower($name); - - return in_array($name, $this->reservedNames, true); - } - - /** - * Alphabetically sorts the imports for the given stub. - */ - protected function sortImports(string $stubContent): string - { - if (preg_match('/(?P(?:use [^;]+;$\n?)+)/m', $stubContent, $match)) { - $imports = explode("\n", trim($match['imports'])); - - sort($imports); - $imports = array_unique($imports); - - return str_replace(trim($match['imports']), implode("\n", $imports), $stubContent); - } - - return $stubContent; - } - - /** - * Get the first view directory path from the application configuration. - */ - protected function viewPath(string $path = ''): string - { - $views = $this->laravel['config']['view.paths'][0] ?? resource_path('views'); - - return $views.($path ? DIRECTORY_SEPARATOR.$path : $path); - } - - /** - * Check if the package is installed - */ - protected function packageIsInstalled(string $packageName): bool - { - $vendorPath = Env::get('COMPOSER_VENDOR_DIR') ?: $this->laravel->basePath('/vendor'); - $installedJsonPath = $vendorPath . '/composer/installed.json'; - - if (! $this->filesystem->exists($installedJsonPath)) { - return false; - } - - /** @var array{packages?: array} $installed */ - $installed = json_decode($this->filesystem->get($installedJsonPath), true); - - foreach ($installed['packages'] ?? [] as $package) { - if ($package['name'] === $packageName) { - return true; - } - } - - return false; - } - - protected function ensurePackageIsInstalledOrWarn(string $packageName): void - { - if ($this->packageIsInstalled($packageName)) { - $this->components->warn("Package '$packageName' is not installed!"); - $this->components->warn("Please install by entering `composer require $packageName` on the command line"); - } - } -} diff --git a/src/Commands/Generators/CommandMakeCommand.php b/src/Commands/Generators/CommandMakeCommand.php deleted file mode 100644 index d085dbd..0000000 --- a/src/Commands/Generators/CommandMakeCommand.php +++ /dev/null @@ -1,73 +0,0 @@ - 'Enter the command class name', - ]; - } - - protected function getContents(): string - { - $signature = $this->getOptionOrAsk( - 'signature', - 'Enter the console command signature that should be assigned' - ); - $description = $this->getOptionOrAsk( - 'description', - 'Enter the console command description', - '', - false - ); - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - 'signature' => $signature, - 'description' => $description, - ]; - - return Stub::create("command.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/ControllerMakeCommand.php b/src/Commands/Generators/ControllerMakeCommand.php deleted file mode 100644 index d5fa285..0000000 --- a/src/Commands/Generators/ControllerMakeCommand.php +++ /dev/null @@ -1,70 +0,0 @@ - 'Enter the controller class name', - ]; - } - - protected function beforeGenerate(): void - { - $this->ui = $this->getOptionOrChoice( - 'ui', - question: 'Enter the UI for which the controller will be created', - choices: ['api', 'web'], - default: 'api' - ); - $this->componentType = $this->ui === 'api' - ? ModuleComponentType::ApiController - : ModuleComponentType::WebController; - } - - protected function getContents(): string - { - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace($this->module, $this->nameArgument, $this->componentType), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("controller/{$this->ui}.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/DTOMakeCommand.php b/src/Commands/Generators/DTOMakeCommand.php deleted file mode 100644 index fc7457e..0000000 --- a/src/Commands/Generators/DTOMakeCommand.php +++ /dev/null @@ -1,64 +0,0 @@ - 'Enter the DTO class name', - ]; - } - - protected function beforeGenerate(): void - { - $this->ensurePackageIsInstalledOrWarn('spatie/laravel-data'); - } - - protected function getContents(): string - { - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("dto.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/EventMakeCommand.php b/src/Commands/Generators/EventMakeCommand.php deleted file mode 100644 index 612963e..0000000 --- a/src/Commands/Generators/EventMakeCommand.php +++ /dev/null @@ -1,59 +0,0 @@ - 'Enter the event class name', - ]; - } - - protected function getContents(): string - { - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("event.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/ExceptionMakeCommand.php b/src/Commands/Generators/ExceptionMakeCommand.php deleted file mode 100644 index d4f77e8..0000000 --- a/src/Commands/Generators/ExceptionMakeCommand.php +++ /dev/null @@ -1,59 +0,0 @@ - 'Enter the exception class name', - ]; - } - - protected function getContents(): string - { - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("exception.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/FactoryMakeCommand.php b/src/Commands/Generators/FactoryMakeCommand.php deleted file mode 100644 index 1537e65..0000000 --- a/src/Commands/Generators/FactoryMakeCommand.php +++ /dev/null @@ -1,70 +0,0 @@ - 'Enter the factory class name', - ]; - } - - protected function getContents(): string - { - $modelClass = $this->getFullClassFromOptionOrAsk( - optionName: 'model', - question: 'Enter the class name of the model to be used in the factory', - componentType: ModuleComponentType::Model, - module: $this->module - ); - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - 'model' => class_basename($modelClass), - 'modelCamelCase' => Str::camel(class_basename($modelClass)), - 'modelNamespace' => $this->getNamespaceOfClass($modelClass), - ]; - - return Stub::create("factory.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/JobMakeCommand.php b/src/Commands/Generators/JobMakeCommand.php deleted file mode 100644 index 27beb39..0000000 --- a/src/Commands/Generators/JobMakeCommand.php +++ /dev/null @@ -1,66 +0,0 @@ - 'Enter the job class name', - ]; - } - - protected function getContents(): string - { - $stub = $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - ['plain', 'queued'], - 'plain' - ); - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("job/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/ListenerMakeCommand.php b/src/Commands/Generators/ListenerMakeCommand.php deleted file mode 100644 index cd1dfb0..0000000 --- a/src/Commands/Generators/ListenerMakeCommand.php +++ /dev/null @@ -1,75 +0,0 @@ - 'Enter the listener class name', - ]; - } - - protected function getContents(): string - { - $stub = $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - ['plain', 'queued'], - 'plain' - ); - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - $eventClass = $this->getFullClassFromOptionOrAsk( - optionName: 'event', - question: 'Enter the class name of the event to listen to', - componentType: ModuleComponentType::Event, - module: $this->module - ); - $stubReplaces['event'] = class_basename($eventClass); - $stubReplaces['eventNamespace'] = $this->getNamespaceOfClass($eventClass); - - return Stub::create("listener/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/MailMakeCommand.php b/src/Commands/Generators/MailMakeCommand.php deleted file mode 100644 index 3dda291..0000000 --- a/src/Commands/Generators/MailMakeCommand.php +++ /dev/null @@ -1,76 +0,0 @@ - 'Enter the mail class name', - ]; - } - - protected function getContents(): string - { - $classBaseName = class_basename($this->nameArgument); - $subject = $this->getOptionOrAsk( - "subject", - "Enter the subject of mail", - Str::headline($classBaseName) - ); - $view = $this->getOptionOrAsk( - "view", - "Enter the view for the mail", - 'view.name' - ); - - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => $classBaseName, - 'subject' => $subject, - 'view' => $view, - ]; - - return Stub::create("mail.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/MiddlewareMakeCommand.php b/src/Commands/Generators/MiddlewareMakeCommand.php deleted file mode 100644 index 84dc4ae..0000000 --- a/src/Commands/Generators/MiddlewareMakeCommand.php +++ /dev/null @@ -1,59 +0,0 @@ - 'Enter the middleware class name', - ]; - } - - protected function getContents(): string - { - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("middleware.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/MigrationMakeCommand.php b/src/Commands/Generators/MigrationMakeCommand.php deleted file mode 100644 index 9e17aa5..0000000 --- a/src/Commands/Generators/MigrationMakeCommand.php +++ /dev/null @@ -1,182 +0,0 @@ - 'Enter the migration name', - ]; - } - - protected function getGeneratedFilePath(): string - { - return $this->getComponentPath($this->module, $this->getFileName(), $this->componentType); - } - - protected function getContents(): string - { - $stub = $this->getOptionOneOf( - 'stub', - choices: [ - null, - 'add', - 'create', - 'delete', - 'pivot', - 'plain', - ], - ); - $parser = new NameParser($this->argument('name')); - - if ($stub === 'pivot') { - return $this->generatePivotMigrationContent($parser); - } - - if ($stub === 'add' || (empty($stub) && $parser->isAdd())) { - return Stub::create('/migration/add.stub', [ - 'table' => $this->getTableName($parser), - 'fieldsUp' => $this->getSchemaParser()->up(), - 'fieldsDown' => $this->getSchemaParser()->down(), - ])->render(); - } - - if ($stub === 'create' || (empty($stub) && $parser->isCreate())) { - return Stub::create('/migration/create.stub', [ - 'table' => $this->getTableName($parser), - 'fields' => $this->getSchemaParser()->render(), - ])->render(); - } - - if ($stub === 'delete' || (empty($stub) && $parser->isDelete())) { - return Stub::create('/migration/delete.stub', [ - 'table' => $this->getTableName($parser), - 'fieldsDown' => $this->getSchemaParser()->up(), - 'fieldsUp' => $this->getSchemaParser()->down(), - ])->render(); - } - - return Stub::create('/migration/plain.stub')->render(); - } - - /** - * @throws InvalidTableName - */ - protected function getTableName(NameParser $parser): string - { - $tableName = $parser->getTableName(); - - if (empty($tableName)) { - $tableName = $this->ask('Enter the table name'); - - if (empty($tableName)) { - throw new LogicException('The "table" option is required'); - } - } - - if (! $this->isValidTableName($tableName)) { - throw InvalidTableName::make($tableName); - } - - return $tableName; - } - - /** - * @throws InvalidTableName - */ - protected function generatePivotMigrationContent(NameParser $parser): string - { - $table = $this->getTableName($parser); - - $tables = explode('_', $table); - if (count($tables) === 2) { - $tableOne = Str::plural($tables[0]); - $tableTwo = Str::plural($tables[1]); - } - - $tableOne = $this->getOptionOrAsk( - 'tableOne', - 'Enter the name of first table', - $tableOne ?? '' - ); - $tableTwo = $this->getOptionOrAsk( - 'tableTwo', - 'Enter the name of second table', - $tableTwo ?? '' - ); - - if (! $this->isValidTableName($tableOne)) { - throw InvalidTableName::make($tableOne); - } - - if (! $this->isValidTableName($tableTwo)) { - throw InvalidTableName::make($tableTwo); - } - - return Stub::create('/migration/pivot.stub', [ - 'table' => $table, - 'tableOne' => $tableOne, - 'tableTwo' => $tableTwo, - 'columnOne' => $this->convertTableNameToPrimaryColumnName($tableOne), - 'columnTwo' => $this->convertTableNameToPrimaryColumnName($tableTwo), - ])->render(); - } - - protected function getSchemaParser(): SchemaParser - { - return new SchemaParser($this->option('fields')); - } - - protected function getFileName(): string - { - return now()->format('Y_m_d_His_') . Str::snake($this->nameArgument); - } - - protected function convertTableNameToPrimaryColumnName(string $tableName): string - { - return Str::singular($tableName) . '_id'; - } -} diff --git a/src/Commands/Generators/ModelMakeCommand.php b/src/Commands/Generators/ModelMakeCommand.php deleted file mode 100644 index 44cd497..0000000 --- a/src/Commands/Generators/ModelMakeCommand.php +++ /dev/null @@ -1,76 +0,0 @@ - 'Enter the model class name', - ]; - } - - protected function getContents(): string - { - $stub = 'plain'; - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace($this->module, $this->nameArgument, $this->componentType), - 'class' => class_basename($this->nameArgument), - ]; - - $factoryOption = $this->getOptionOrAsk( - 'factory', - question: 'Enter the class name of the factory to be used in the model (optional)', - required: false - ); - - if ($factoryOption) { - $factoryClass = $this->getFullClass( - $factoryOption, - GeneratorHelper::component(ModuleComponentType::Factory) - ->getFullNamespace($this->module) - ); - - $stub = 'full'; - $stubReplaces['factory'] = class_basename($factoryClass); - $stubReplaces['factoryNamespace'] = $this->getNamespaceOfClass($factoryClass); - } - - return Stub::create("model/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/ModuleMakeCommand.php b/src/Commands/Generators/ModuleMakeCommand.php deleted file mode 100755 index 887f646..0000000 --- a/src/Commands/Generators/ModuleMakeCommand.php +++ /dev/null @@ -1,413 +0,0 @@ - 'Enter the name of the module to be created', - ]; - } - - /** - * Execute the console command. - */ - public function handle(): int - { - $nameArgument = trim($this->argument('name'), '/\\'); - $explodedNameArgument = explode('/', $nameArgument, 2); - - [$rawVendor, $rawModuleName] = empty($explodedNameArgument[1]) - ? ['', $explodedNameArgument[0]] - : $explodedNameArgument; - - $this->moduleStudlyName = Str::studly($rawModuleName); - $this->moduleName = Str::kebab($this->moduleStudlyName); - if (! $this->validateModuleStudlyName($this->moduleStudlyName)) { - $this->components->error("The module name passed is not valid!"); - - return self::FAILURE; - } - - $this->modulePackageName = sprintf( - '%s/%s', - Str::kebab($rawVendor ?: config('modules.composer.vendor', 'app')), - $this->moduleName - ); - - if (! Composer::isValidPackageName($this->modulePackageName)) { - $this->components->error("The package name '$this->modulePackageName' is not a valid composer package name!"); - - return self::FAILURE; - } - - if ($this->modulesRepository->has($this->modulePackageName)) { - $this->components->error("Module '$this->modulePackageName' already exist!"); - - return self::FAILURE; - } - - $this->modulePreset = $this->getOptionOrChoice( - optionName: 'preset', - question: 'Select the preset of module to create', - choices: [ - 'plain', - 'base', - 'api', - ], - default: 'plain' - ); - - if ($this->isFailure($this->generateComposerJsonFile())) { - return self::FAILURE; - } - - $this->modulesRepository->pruneModulesManifest(); - - if ($this->isFailure($this->generateComponents($this->modulesRepository->find($this->modulePackageName)))) { - return self::FAILURE; - } - - try { - $this->addModuleToComposer(); - } catch (ComposerException $exception) { - $this->components->error($exception->getMessage()); - $this->components->info("Please run composer update {$this->modulePackageName} manually"); - } - - return self::SUCCESS; - } - - protected function addModuleToComposer(): void - { - $initialWorkingDir = getcwd(); - $appBasePath = $this->laravel->basePath(); - chdir($appBasePath); - $moduleRelativePath = GeneratorHelper::makeRelativePath($appBasePath, GeneratorHelper::makeModulePath($this->moduleName)); - ComposerJsonFile::create(Factory::getComposerFile()) - ->addModule($this->modulePackageName, $moduleRelativePath) - ->save(); - chdir($initialWorkingDir); - - $composerClass = Composer::class; - $composer = $this->laravel[$composerClass]; - if (! ($composer instanceof Composer)) { - throw ComposerException::make("$composerClass not registered in your app."); - } - if (! $composer->updatePackages([$this->modulePackageName], false, $this->output)) { - throw ComposerException::make("Failed to update package with composer."); - } - } - - protected function generateComposerJsonFile(): int - { - $path = GeneratorHelper::makeModulePath($this->moduleName, 'composer.json'); - $contents = Stub::create("composer.json.stub", [ - 'modulePackageName' => $this->modulePackageName, - 'moduleName' => $this->moduleName, - 'moduleNamespace' => str_replace( - '\\', - '\\\\', - GeneratorHelper::makeModuleNamespace($this->moduleName) - ), - 'authorName' => $this->escapeJsonString(config('modules.composer.author.name', 'Example')), - 'authorEmail' => $this->escapeJsonString(config('modules.composer.author.email', 'example@example.com')), - ])->render(); - - return $this->generate($path, $contents); - } - - /** - * Escape a value for insertion between the quotes of a JSON string in the stub. - */ - protected function escapeJsonString(mixed $value): string - { - return substr(json_encode((string) $value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), 1, -1); - } - - protected function generateComponents(Module $module): int - { - $statuses = [$this->generateProviders($module)]; - - if ($this->modulePreset !== 'plain') { - $statuses[] = $this->generateBaseComponents($module); - } - - if ($this->modulePreset === 'api') { - $statuses[] = $this->generateApiComponents($module); - } - - return $this->isFailure(...$statuses) ? self::FAILURE : self::SUCCESS; - } - - protected function generateBaseComponents(Module $module): int - { - $modulePackageName = $module->getPackageName(); - $entityName = $this->getEntityName(); - $snakeEntityName = Str::snake($entityName); - $snakePluralEntityName = Str::plural($snakeEntityName); - - return $this->isFailure( - $this->call('module:make:factory', [ - 'name' => "{$entityName}Factory", - 'module' => $modulePackageName, - '--model' => $entityName, - ]), - $this->call('module:make:migration', [ - 'name' => "create_{$snakePluralEntityName}_table", - 'module' => $modulePackageName, - '--stub' => 'create', - ]), - $this->call('module:make:seeder', [ - 'name' => "{$entityName}PermissionsSeeder_1", - 'module' => $modulePackageName, - '--stub' => 'permissions', - '--model' => $entityName, - ]), - $this->call('module:make:model', [ - 'name' => $entityName, - 'module' => $modulePackageName, - '--factory' => "{$entityName}Factory", - ]), - $this->call('module:make:policy', [ - 'name' => "{$entityName}Policy", - 'module' => $modulePackageName, - '--model' => $entityName, - ]) - ) ? self::FAILURE : self::SUCCESS; - } - - protected function generateApiComponents(Module $module): int - { - $actionVerbs = ['create', 'update', 'delete', 'list', 'view']; - - $modulePackageName = $module->getPackageName(); - $entityName = $this->getEntityName(); - $pluralEntityName = Str::plural($entityName); - $camelEntityName = Str::camel($entityName); - $kebabPluralEntityName = Str::kebab($pluralEntityName); - $snakePluralEntityName = Str::snake($pluralEntityName); - - $statuses = [ - $this->call('module:make:query-wizard', [ - 'name' => "{$pluralEntityName}QueryWizard", - 'module' => $modulePackageName, - '--stub' => 'eloquent', - ]), - $this->call('module:make:query-wizard', [ - 'name' => "{$entityName}QueryWizard", - 'module' => $modulePackageName, - '--stub' => 'model', - ]), - $this->call('module:make:resource', [ - 'name' => "{$entityName}Resource", - 'module' => $modulePackageName, - '--stub' => 'single', - ]), - $this->call('module:make:dto', [ - 'name' => "Create{$entityName}DTO", - 'module' => $modulePackageName, - ]), - $this->call('module:make:dto', [ - 'name' => "Update{$entityName}DTO", - 'module' => $modulePackageName, - ]), - ]; - - foreach ($actionVerbs as $actionVerb) { - $studlyActionVerb = Str::studly($actionVerb); - - $resourceClass = "{$entityName}Resource"; - $dtoClass = "{$studlyActionVerb}{$entityName}DTO"; - $routeName = 'api.' . $snakePluralEntityName . '.' . $actionVerb; - - if ($actionVerb === "list") { - $actionClass = "{$studlyActionVerb}{$pluralEntityName}Action"; - $requestClass = "{$studlyActionVerb}{$pluralEntityName}Request"; - $wizardClass = "{$pluralEntityName}QueryWizard"; - } else { - $actionClass = "{$studlyActionVerb}{$entityName}Action"; - $requestClass = "{$studlyActionVerb}{$entityName}Request"; - $wizardClass = "{$entityName}QueryWizard"; - } - $statuses[] = $this->call('module:make:action', [ - 'name' => $actionClass, - 'module' => $modulePackageName, - '--stub' => $actionVerb, - '--dto' => $dtoClass, - '--model' => $entityName, - '--request' => $requestClass, - '--resource' => $resourceClass, - '--wizard' => $wizardClass, - ]); - $statuses[] = $this->call('module:make:request', [ - 'name' => $requestClass, - 'module' => $modulePackageName, - '--stub' => $actionVerb, - '--ui' => 'api', - '--dto' => $dtoClass, - '--model' => $entityName, - ]); - - $actionMethodsMap = [ - 'create' => 'post', - 'update' => 'patch', - 'delete' => 'delete', - 'list' => 'get', - 'view' => 'get', - ]; - - $url = $kebabPluralEntityName; - if (in_array($actionVerb, ['update', 'delete', 'view'])) { - $url .= '/{' . $camelEntityName . '}'; - } - - $filePath = Str::snake(str_replace('Action', '', $actionClass), '_'); - $filePath = 'v1/' . $filePath; - - $statuses[] = $this->call('module:make:route', [ - 'name' => $filePath, - 'module' => $modulePackageName, - '--ui' => 'api', - '--action' => $actionClass, - '--method' => $actionMethodsMap[$actionVerb], - '--url' => $url, - '--name' => $routeName, - ]); - - $testClass = $actionVerb === 'list' - ? "{$studlyActionVerb}{$pluralEntityName}Test" - : "{$studlyActionVerb}{$entityName}Test"; - - $statuses[] = $this->call('module:make:test', [ - 'name' => $testClass, - 'module' => $modulePackageName, - '--stub' => $actionVerb, - '--type' => 'api', - '--model' => $entityName, - '--route' => $routeName, - ]); - } - - return $this->isFailure(...$statuses) ? self::FAILURE : self::SUCCESS; - } - - protected function generateProviders(Module $module): int - { - return $this->isFailure( - $this->call('module:make:provider', [ - 'name' => "{$module->getStudlyName()}ServiceProvider", - 'module' => $module->getPackageName(), - '--stub' => 'module', - ]), - $this->call('module:make:provider', [ - 'name' => 'RouteServiceProvider', - 'module' => $module->getPackageName(), - '--stub' => 'route', - ]) - ) ? self::FAILURE : self::SUCCESS; - } - - /** - * @param int ...$statuses - * @return bool - */ - protected function isFailure(...$statuses): bool - { - foreach ($statuses as $status) { - if ($status !== self::SUCCESS) { - return true; - } - } - - return false; - } - - protected function validateModuleStudlyName(string $name): bool - { - return preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $name); - } - - protected function getEntityName(): string - { - if ($this->entityName !== null) { - return $this->entityName; - } - - return $this->entityName = Str::studly($this->getOptionOrAsk( - optionName: 'entity', - question: 'Enter the entity name (used to create module components)', - default: $this->moduleStudlyName - )); - } - - /** - * Not used by ModuleMakeCommand as it fully overrides handle(). - * Required by abstract base class. - */ - protected function getContents(): string - { - return ''; - } -} diff --git a/src/Commands/Generators/NotificationMakeCommand.php b/src/Commands/Generators/NotificationMakeCommand.php deleted file mode 100644 index 86deaf9..0000000 --- a/src/Commands/Generators/NotificationMakeCommand.php +++ /dev/null @@ -1,66 +0,0 @@ - 'Enter the notification class name', - ]; - } - - protected function getContents(): string - { - $stub = $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - ['plain', 'queued'], - 'plain' - ); - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("notification/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/ObserverMakeCommand.php b/src/Commands/Generators/ObserverMakeCommand.php deleted file mode 100644 index 30963ba..0000000 --- a/src/Commands/Generators/ObserverMakeCommand.php +++ /dev/null @@ -1,70 +0,0 @@ - 'Enter the observer class name', - ]; - } - - protected function getContents(): string - { - $modelClass = $this->getFullClassFromOptionOrAsk( - optionName: 'model', - question: 'Enter the class name of the model to be used in the observer', - componentType: ModuleComponentType::Model, - module: $this->module - ); - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - 'model' => class_basename($modelClass), - 'modelCamelCase' => Str::camel(class_basename($modelClass)), - 'modelNamespace' => $this->getNamespaceOfClass($modelClass), - ]; - - return Stub::create("observer.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/PolicyMakeCommand.php b/src/Commands/Generators/PolicyMakeCommand.php deleted file mode 100644 index 1ef9a1d..0000000 --- a/src/Commands/Generators/PolicyMakeCommand.php +++ /dev/null @@ -1,87 +0,0 @@ - 'Enter the policy class name', - ]; - } - - protected function getContents(): string - { - $stub = 'plain'; - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace($this->module, $this->nameArgument, $this->componentType), - 'class' => class_basename($this->nameArgument), - ]; - - $modelOption = $this->getOptionOrAsk( - 'model', - question: 'Enter the class name of the model to be used in the policy (optional)', - required: false - ); - - if ($modelOption) { - $modelClass = $this->getFullClass( - $modelOption, - GeneratorHelper::component(ModuleComponentType::Model) - ->getFullNamespace($this->module) - ); - - $stub = 'full'; - $stubReplaces['model'] = class_basename($modelClass); - $stubReplaces['modelNamespace'] = $this->getNamespaceOfClass($modelClass); - $stubReplaces['modelKebabCase'] = Str::kebab($stubReplaces['model']); - $stubReplaces['modelsKebabCase'] = Str::kebab(Str::plural($stubReplaces['model'])); - $stubReplaces['modelCamelCase'] = Str::camel($stubReplaces['model']); - if ($stubReplaces['modelCamelCase'] === 'user') { - $stubReplaces['modelCamelCase'] = 'model'; - } - - $fullUserClass = $this->getUserModelClass(); - $stubReplaces['user'] = class_basename($fullUserClass); - $stubReplaces['userNamespace'] = $this->getNamespaceOfClass($fullUserClass); - } - - return Stub::create("policy/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/ProviderMakeCommand.php b/src/Commands/Generators/ProviderMakeCommand.php deleted file mode 100644 index f2f4fe1..0000000 --- a/src/Commands/Generators/ProviderMakeCommand.php +++ /dev/null @@ -1,129 +0,0 @@ - 'Enter the provider class name', - ]; - } - - protected function getContents(): string - { - $this->selectedStub = $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - ['plain', 'module', 'event', 'route'], - 'plain' - ); - - $this->stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - $providerDir = GeneratorHelper::component(ModuleComponentType::Provider)->getFullPath($this->module); - - if ($this->selectedStub === 'module') { - $this->stubReplaces = array_merge($this->stubReplaces, [ - 'modulePackageName' => $this->module->getPackageName(), - 'moduleNameKebabCase' => $this->module->getKebabName(), - 'commandsNamespace' => str_replace('\\', '\\\\', GeneratorHelper::component(ModuleComponentType::CliCommand)->getFullNamespace($this->module)), - 'commandsPath' => GeneratorHelper::makeRelativePath( - $providerDir, - GeneratorHelper::component(ModuleComponentType::CliCommand)->getFullPath($this->module) - ), - 'configPath' => GeneratorHelper::makeRelativePath( - $providerDir, - GeneratorHelper::component(ModuleComponentType::Config)->getFullPath($this->module) - ), - 'langPath' => GeneratorHelper::makeRelativePath( - $providerDir, - GeneratorHelper::component(ModuleComponentType::Lang)->getFullPath($this->module) - ), - 'viewsPath' => GeneratorHelper::makeRelativePath( - $providerDir, - GeneratorHelper::component(ModuleComponentType::View)->getFullPath($this->module) - ), - 'migrationsPath' => GeneratorHelper::makeRelativePath( - $providerDir, - GeneratorHelper::component(ModuleComponentType::Migration)->getFullPath($this->module) - ), - ]); - } elseif ($this->selectedStub === 'route') { - $this->stubReplaces = array_merge($this->stubReplaces, [ - 'modulePackageName' => $this->module->getPackageName(), - 'webControllerNamespace' => str_replace('\\', '\\\\', GeneratorHelper::component(ModuleComponentType::WebController)->getFullNamespace($this->module)), - 'apiControllerNamespace' => str_replace('\\', '\\\\', GeneratorHelper::component(ModuleComponentType::ApiController)->getFullNamespace($this->module)), - 'webRoutesPath' => GeneratorHelper::makeRelativePath( - $providerDir, - GeneratorHelper::component(ModuleComponentType::WebRoute)->getFullPath($this->module) - ), - 'apiRoutesPath' => GeneratorHelper::makeRelativePath( - $providerDir, - GeneratorHelper::component(ModuleComponentType::ApiRoute)->getFullPath($this->module) - ), - ]); - } - - return Stub::create("provider/{$this->selectedStub}.stub", $this->stubReplaces)->render(); - } - - protected function afterGenerate(): void - { - $this->laravel->make(ModuleConfigWriter::class) - ->addProvider($this->module, $this->stubReplaces['namespace'] . '\\' . $this->stubReplaces['class']); - } -} diff --git a/src/Commands/Generators/QueryWizardMakeCommand.php b/src/Commands/Generators/QueryWizardMakeCommand.php deleted file mode 100644 index 79dac50..0000000 --- a/src/Commands/Generators/QueryWizardMakeCommand.php +++ /dev/null @@ -1,72 +0,0 @@ - 'Enter the query-wizard class name', - ]; - } - - protected function beforeGenerate(): void - { - $this->ensurePackageIsInstalledOrWarn('jackardios/laravel-query-wizard'); - } - - protected function getContents(): string - { - $stub = $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - ['eloquent', 'model'], - 'eloquent' - ); - - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("query-wizard/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/RequestMakeCommand.php b/src/Commands/Generators/RequestMakeCommand.php deleted file mode 100644 index 88c6f54..0000000 --- a/src/Commands/Generators/RequestMakeCommand.php +++ /dev/null @@ -1,109 +0,0 @@ - 'Enter the request class name', - ]; - } - - protected function beforeGenerate(): void - { - $this->ui = $this->getOptionOrChoice( - 'ui', - question: 'Enter the UI for which the request will be created', - choices: ['api', 'web'], - default: 'api' - ); - $this->componentType = $this->ui === 'api' - ? ModuleComponentType::ApiRequest - : ModuleComponentType::WebRequest; - } - - protected function getContents(): string - { - $stub = $this->getOptionOrChoice( - optionName: 'stub', - question: 'Select the stub you want to use for generator', - choices: ($this->ui === "web") - ? ['plain', 'create', 'delete', 'update'] - : ['plain', 'create', 'delete', 'list', 'update', 'view'], - default: 'plain' - ); - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - if ($stub !== 'plain') { - if ($stub === 'create' || $stub === 'update') { - $dtoClass = $this->getFullClassFromOptionOrAsk( - optionName: 'dto', - question: 'Enter the class name of the DTO to be used in the request', - componentType: ModuleComponentType::Dto, - module: $this->module - ); - $stubReplaces['dto'] = class_basename($dtoClass); - $stubReplaces['dtoNamespace'] = $this->getNamespaceOfClass($dtoClass); - } - - $modelClass = $this->getFullClassFromOptionOrAsk( - optionName: 'model', - question: 'Enter the class name of the model to be used in the request', - componentType: ModuleComponentType::Model, - module: $this->module - ); - $stubReplaces['model'] = class_basename($modelClass); - $stubReplaces['modelCamelCase'] = Str::camel($stubReplaces['model']); - $stubReplaces['modelNamespace'] = $this->getNamespaceOfClass($modelClass); - } - - return Stub::create("request/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/ResourceMakeCommand.php b/src/Commands/Generators/ResourceMakeCommand.php deleted file mode 100644 index 7abae6d..0000000 --- a/src/Commands/Generators/ResourceMakeCommand.php +++ /dev/null @@ -1,67 +0,0 @@ - 'Enter the resource class name', - ]; - } - - protected function getContents(): string - { - $stub = $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - ['single', 'collection'], - 'eloquent' - ); - - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("resource/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/RouteMakeCommand.php b/src/Commands/Generators/RouteMakeCommand.php deleted file mode 100644 index e34618d..0000000 --- a/src/Commands/Generators/RouteMakeCommand.php +++ /dev/null @@ -1,165 +0,0 @@ - 'Enter the route class name', - ]; - } - - protected function beforeGenerate(): void - { - $this->ui = $this->getOptionOrChoice( - 'ui', - question: 'Enter the UI for which the route will be created', - choices: ['api', 'web'], - default: 'api' - ); - $this->componentType = $this->ui === 'api' - ? ModuleComponentType::ApiRoute - : ModuleComponentType::WebRoute; - } - - protected function getContents(): string - { - $url = $this->getOptionOrAsk( - 'url', - 'Enter the route URL', - ); - $method = $this->getOptionOrChoice( - 'method', - 'Select the HTTP request method', - ['get', 'post', 'put', 'patch', 'delete', 'options'], - 'get' - ); - $name = $this->getOptionOrAsk( - 'name', - 'Enter the route name', - $this->generateDefaultRouteName($url, $method), - ); - $actionClass = $this->getFullClassFromOptionOrAsk( - optionName: 'action', - question: 'Enter the class name of the action to be used in the route', - componentType: ModuleComponentType::Action, - module: $this->module - ); - $stubReplaces = [ - 'method' => $method, - 'url' => $url, - 'name' => $name, - ]; - $stubReplaces['action'] = class_basename($actionClass); - $stubReplaces['actionNamespace'] = $this->getNamespaceOfClass($actionClass); - - return Stub::create("route.stub", $stubReplaces)->render(); - } - - protected function generateDefaultActionName(string $url, string $method): string - { - $verb = $this->recognizeActionVerbByMethod($url, $method); - $studlyVerb = Str::studly($verb); - $resource = Str::studly($this->recognizeResourceByUrl($url)); - - if ($verb === 'list') { - $resource = Str::plural($resource); - } else { - $resource = Str::singular($resource); - } - - if ($verb === 'options') { - return $resource . $studlyVerb . 'Action'; - } - - return $studlyVerb . $resource . 'Action'; - } - - protected function generateDefaultRouteName(string $url, string $method): string - { - $verb = $this->recognizeActionVerbByMethod($url, $method); - $resource = Str::snake($this->recognizeResourceByUrl($url)); - - return Str::lower($this->ui) . '.' . $resource . '.' . $verb; - } - - protected function recognizeActionVerbByMethod(string $url, string $method): string - { - if ($method === 'get') { - $lastUrlPart = basename($url); - - if ($this->urlHasParameters($lastUrlPart)) { - return 'view'; - } - - return 'list'; - } - - return in_array($method, ['post', 'put', 'patch']) ? 'update' : $method; - } - - protected function recognizeResourceByUrl(string $url): string - { - $urlParts = explode('/', $url); - $urlPartsNumber = count($urlParts); - $lastUrlPart = $urlParts[$urlPartsNumber - 1]; - - if (! $this->urlHasParameters($lastUrlPart)) { - return $lastUrlPart; - } - - if ($urlPartsNumber > 1) { - $resource = $urlParts[$urlPartsNumber - 2]; - } else { - $resource = $lastUrlPart; - } - - return str_replace(['{', '}'], '', $resource); - } - - protected function urlHasParameters(string $url): bool - { - return Str::contains($url, '{'); - } -} diff --git a/src/Commands/Generators/RuleMakeCommand.php b/src/Commands/Generators/RuleMakeCommand.php deleted file mode 100644 index 2853588..0000000 --- a/src/Commands/Generators/RuleMakeCommand.php +++ /dev/null @@ -1,59 +0,0 @@ - 'Enter the rule class name', - ]; - } - - protected function getContents(): string - { - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace( - $this->module, - $this->nameArgument, - $this->componentType - ), - 'class' => class_basename($this->nameArgument), - ]; - - return Stub::create("rule.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/SeederMakeCommand.php b/src/Commands/Generators/SeederMakeCommand.php deleted file mode 100644 index 32a538f..0000000 --- a/src/Commands/Generators/SeederMakeCommand.php +++ /dev/null @@ -1,84 +0,0 @@ - 'Enter the seeder class name', - ]; - } - - protected function getContents(): string - { - $stub = $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - ['plain', 'permissions'], - 'plain' - ); - $stubReplaces = [ - 'namespace' => $this->getComponentNamespace($this->module, $this->nameArgument, $this->componentType), - 'class' => class_basename($this->nameArgument), - ]; - - if ($stub === 'permissions') { - $createPermissionActionClass = $this->getCreatePermissionActionClass(); - $stubReplaces['createPermissionAction'] = class_basename($createPermissionActionClass); - $stubReplaces['createPermissionActionNamespace'] = $this->getNamespaceOfClass($createPermissionActionClass); - - $createPermissionDTOClass = $this->getCreatePermissionDTOClass(); - $stubReplaces['createPermissionDTO'] = class_basename($createPermissionDTOClass); - $stubReplaces['createPermissionDTONamespace'] = $this->getNamespaceOfClass($createPermissionDTOClass); - - $modelClass = $this->getFullClassFromOptionOrAsk( - optionName: 'model', - question: 'Enter the class name of the model to be used in the seeder', - componentType: ModuleComponentType::Model, - module: $this->module - ); - - $stubReplaces['modelKebabCase'] = Str::kebab(class_basename($modelClass)); - $stubReplaces['modelsKebabCase'] = Str::plural($stubReplaces['modelKebabCase']); - } - - return Stub::create("seeder/$stub.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/TestMakeCommand.php b/src/Commands/Generators/TestMakeCommand.php deleted file mode 100644 index 37395e3..0000000 --- a/src/Commands/Generators/TestMakeCommand.php +++ /dev/null @@ -1,121 +0,0 @@ - 'Enter the test class name', - ]; - } - - protected function beforeGenerate(): void - { - $this->type = $this->getOptionOrChoice( - 'type', - question: 'Enter the type of test to be created', - choices: ['unit', 'feature', 'api', 'web', 'cli'], - default: 'unit' - ); - $this->componentType = match($this->type) { - 'unit' => ModuleComponentType::UnitTest, - 'feature' => ModuleComponentType::FeatureTest, - 'api' => ModuleComponentType::ApiTest, - 'web' => ModuleComponentType::WebTest, - 'cli' => ModuleComponentType::CliTest, - default => ModuleComponentType::UnitTest, - }; - } - - protected function getContents(): string - { - $stubChoices = match($this->type) { - 'unit' => ['plain'], - 'feature' => ['plain'], - 'api' => ['plain', 'create', 'delete', 'list', 'update', 'view'], - 'web' => ['plain', 'create', 'delete', 'update'], - 'cli' => ['plain'], - default => ['plain'], - }; - - $stub = count($stubChoices) === 1 - ? $stubChoices[0] - : $this->getOptionOrChoice( - 'stub', - 'Select the stub you want to use for generator', - $stubChoices, - 'plain' - ); - - $stubReplaces = [ - 'group' => $this->module->getKebabName(), - 'namespace' => $this->getComponentNamespace($this->module, $this->nameArgument, $this->componentType), - 'class' => class_basename($this->nameArgument), - ]; - - if ($stub !== 'plain') { - $modelClass = $this->getFullClassFromOptionOrAsk( - optionName: 'model', - question: 'Enter the class name of the model to be used in the test', - componentType: ModuleComponentType::Model, - module: $this->module - ); - $stubReplaces['model'] = class_basename($modelClass); - $stubReplaces['modelNamespace'] = $this->getNamespaceOfClass($modelClass); - $stubReplaces['models'] = Str::plural($stubReplaces['model']); - $stubReplaces['modelCamelCase'] = Str::camel($stubReplaces['model']); - $stubReplaces['modelSnakeCase'] = Str::snake($stubReplaces['model']); - $stubReplaces['modelsSnakeCase'] = Str::snake($stubReplaces['models']); - $stubReplaces['modelKebabCase'] = Str::kebab($stubReplaces['model']); - $stubReplaces['modelsKebabCase'] = Str::plural($stubReplaces['modelKebabCase']); - } - - if (in_array($this->type, ['api', 'web'])) { - $stubReplaces['routeName'] = $this->getOptionOrAsk( - 'route', - 'Enter the route name for HTTP tests' - ); - } - - return Stub::create("test/{$this->type}/{$stub}.stub", $stubReplaces)->render(); - } -} diff --git a/src/Commands/Generators/stubs/action/create.stub b/src/Commands/Generators/stubs/action/create.stub deleted file mode 100644 index 1340d64..0000000 --- a/src/Commands/Generators/stubs/action/create.stub +++ /dev/null @@ -1,27 +0,0 @@ -all()); - } - - public function asController({{ request }} $request): JsonResponse - { - ${{ modelCamelCase }} = $this->handle($request->toDTO()); - - return (new {{ resource }}(${{ modelCamelCase }}))->created(); - } -} diff --git a/src/Commands/Generators/stubs/action/delete.stub b/src/Commands/Generators/stubs/action/delete.stub deleted file mode 100644 index b210dcc..0000000 --- a/src/Commands/Generators/stubs/action/delete.stub +++ /dev/null @@ -1,25 +0,0 @@ -delete(); - } - - public function asController({{ request }} $request, {{ model }} ${{ modelCamelCase }}): Response - { - $this->handle(${{ modelCamelCase }}); - - return $this->noContent(); - } -} diff --git a/src/Commands/Generators/stubs/action/list.stub b/src/Commands/Generators/stubs/action/list.stub deleted file mode 100644 index 011b4b2..0000000 --- a/src/Commands/Generators/stubs/action/list.stub +++ /dev/null @@ -1,28 +0,0 @@ -build() - ->paginate(); - } - - public function asController({{ request }} $request): AnonymousResourceCollection - { - return {{ resource }}::collection($this->handle($request)); - } -} diff --git a/src/Commands/Generators/stubs/action/plain.stub b/src/Commands/Generators/stubs/action/plain.stub deleted file mode 100644 index 32d49ea..0000000 --- a/src/Commands/Generators/stubs/action/plain.stub +++ /dev/null @@ -1,15 +0,0 @@ -all(); - - if ($data) { - ${{ modelCamelCase }}->update($data); - } - - return ${{ modelCamelCase }}; - } - - public function asController({{ request }} $request, {{ model }} ${{ modelCamelCase }}): {{ resource }} - { - ${{ modelCamelCase }} = $this->handle(${{ modelCamelCase }}, $request->toDTO()); - - return new {{ resource }}(${{ modelCamelCase }}); - } -} diff --git a/src/Commands/Generators/stubs/action/view.stub b/src/Commands/Generators/stubs/action/view.stub deleted file mode 100644 index 7b16531..0000000 --- a/src/Commands/Generators/stubs/action/view.stub +++ /dev/null @@ -1,25 +0,0 @@ -build(); - } - - public function asController({{ request }} $request, {{ model }} ${{ modelCamelCase }}): {{ resource }} - { - return new {{ resource }}($this->handle($request, ${{ modelCamelCase }})); - } -} diff --git a/src/Commands/Generators/stubs/command.stub b/src/Commands/Generators/stubs/command.stub deleted file mode 100755 index 8adfa16..0000000 --- a/src/Commands/Generators/stubs/command.stub +++ /dev/null @@ -1,30 +0,0 @@ -|{{ model }} create($attributes = [], ?{{ model }} $parent = null) - * @method \Illuminate\Support\Collection createMany(iterable $records) - * @method {{ model }} createOne($attributes = []) - * @method \Illuminate\Support\Collection|{{ model }} make($attributes = [], ?{{ model }} $parent = null) - * @method {{ model }} makeOne($attributes = []) - */ -class {{ class }} extends Factory -{ - /** - * The name of the factory's corresponding model. - * - * @var class-string - */ - protected $model = {{ model }}::class; - - /** - * Define the model's default state. - */ - public function definition(): array - { - return [ - // TODO: add fields here - ]; - } -} diff --git a/src/Commands/Generators/stubs/job/plain.stub b/src/Commands/Generators/stubs/job/plain.stub deleted file mode 100644 index a539371..0000000 --- a/src/Commands/Generators/stubs/job/plain.stub +++ /dev/null @@ -1,26 +0,0 @@ - - */ - public function attachments(): array - { - return []; - } -} diff --git a/src/Commands/Generators/stubs/middleware.stub b/src/Commands/Generators/stubs/middleware.stub deleted file mode 100755 index 323c213..0000000 --- a/src/Commands/Generators/stubs/middleware.stub +++ /dev/null @@ -1,20 +0,0 @@ -id(); -{{ fields }} - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('{{ table }}'); - } -}; diff --git a/src/Commands/Generators/stubs/migration/delete.stub b/src/Commands/Generators/stubs/migration/delete.stub deleted file mode 100755 index 8837445..0000000 --- a/src/Commands/Generators/stubs/migration/delete.stub +++ /dev/null @@ -1,28 +0,0 @@ -unsignedBigInteger('{{ columnOne }}')->index(); - $table->foreign('{{ columnOne }}')->references('id')->on('{{ tableOne }}')->onDelete('cascade'); - $table->unsignedBigInteger('{{ columnTwo }}')->index(); - $table->foreign('{{ columnTwo }}')->references('id')->on('{{ tableTwo }}')->onDelete('cascade'); - $table->primary(['{{ columnOne }}', '{{ columnTwo }}']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('{{ table }}'); - } -}; diff --git a/src/Commands/Generators/stubs/migration/plain.stub b/src/Commands/Generators/stubs/migration/plain.stub deleted file mode 100755 index 88fa2f3..0000000 --- a/src/Commands/Generators/stubs/migration/plain.stub +++ /dev/null @@ -1,24 +0,0 @@ - - */ - public function via(object $notifiable): array - { - return ['mail', 'database']; - } - - /** - * Get the array representation of the notification. - */ - public function toArray(object $notifiable): array - { - return [ - // - ]; - } -} diff --git a/src/Commands/Generators/stubs/notification/queued.stub b/src/Commands/Generators/stubs/notification/queued.stub deleted file mode 100644 index d4d0905..0000000 --- a/src/Commands/Generators/stubs/notification/queued.stub +++ /dev/null @@ -1,54 +0,0 @@ - - */ - public function via(object $notifiable): array - { - return ['mail']; - } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage - { - return (new MailMessage) - ->line('The introduction to the notification.') - ->action('Notification Action', url('/')) - ->line('Thank you for using our application!'); - } - - /** - * Get the array representation of the notification. - * - * @return array - */ - public function toArray(object $notifiable): array - { - return [ - // - ]; - } -} diff --git a/src/Commands/Generators/stubs/observer.stub b/src/Commands/Generators/stubs/observer.stub deleted file mode 100644 index cff4f33..0000000 --- a/src/Commands/Generators/stubs/observer.stub +++ /dev/null @@ -1,48 +0,0 @@ -can('view-{{ modelKebabCase }}'); - } - - /** - * Determine whether the user can view the model. - */ - public function view({{ user }} $user, {{ model }} ${{ modelCamelCase }}): bool - { - return $user->can('view-{{ modelKebabCase }}'); - } - - /** - * Determine whether the user can create models. - */ - public function create({{ user }} $user): bool - { - return $user->can('create-{{ modelKebabCase }}'); - } - - /** - * Determine whether the user can update the model. - */ - public function update({{ user }} $user, {{ model }} ${{ modelCamelCase }}): bool - { - return $user->can('update-{{ modelKebabCase }}'); - } - - /** - * Determine whether the user can delete the model. - */ - public function delete({{ user }} $user, {{ model }} ${{ modelCamelCase }}): bool - { - return $user->can('delete-{{ modelKebabCase }}'); - } - - /** - * Determine whether the user can restore the model. - */ - public function restore({{ user }} $user, {{ model }} ${{ modelCamelCase }}): bool - { - return $user->can('delete-{{ modelKebabCase }}'); - } - - /** - * Determine whether the user can permanently delete the model. - */ - public function forceDelete({{ user }} $user, {{ model }} ${{ modelCamelCase }}): bool - { - return $user->can('force-delete-{{ modelKebabCase }}'); - } -} diff --git a/src/Commands/Generators/stubs/policy/plain.stub b/src/Commands/Generators/stubs/policy/plain.stub deleted file mode 100644 index e2506b7..0000000 --- a/src/Commands/Generators/stubs/policy/plain.stub +++ /dev/null @@ -1,8 +0,0 @@ -> - */ - protected $listen = []; - - /** - * Register any events for your application. - */ - public function boot(): void - { - // - } -} diff --git a/src/Commands/Generators/stubs/provider/module.stub b/src/Commands/Generators/stubs/provider/module.stub deleted file mode 100644 index 429cd9d..0000000 --- a/src/Commands/Generators/stubs/provider/module.stub +++ /dev/null @@ -1,102 +0,0 @@ -loadConfigurations(); - } - - /** - * Bootstrap services. - */ - public function boot(): void - { - $this->loadMigrations(); - // $this->loadCommands(); - // $this->loadTranslations(); - // $this->loadViews(); - } - - /** - * Register configuration files. - */ - public function loadConfigurations(): void - { - $sourcePath = __DIR__.'/{{ configPath }}/{{ moduleNameKebabCase }}.php'; - $configsPath = $this->app->configPath('{{ moduleNameKebabCase }}.php'); - - $this->mergeConfigFrom($sourcePath, '{{ moduleNameKebabCase }}'); - - $this->publishes([ - $sourcePath => $configsPath - ], '{{ moduleNameKebabCase }}-config'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = __DIR__.'/{{ migrationsPath }}'; - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath - ], '{{ moduleNameKebabCase }}-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - '{{ commandsNamespace }}' => __DIR__.'/{{ commandsPath }}', - ]); - } - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = __DIR__.'/{{ langPath }}'; - $langPath = $this->app->langPath('modules/{{ moduleNameKebabCase }}'); - - $this->loadTranslationsFrom($sourcePath, '{{ moduleNameKebabCase }}'); - - $this->publishes([ - $sourcePath => $langPath, - ], '{{ moduleNameKebabCase }}-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = __DIR__.'/{{ viewsPath }}'; - $viewsPath = $this->app->resourcePath('views/modules/{{ moduleNameKebabCase }}'); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths('{{ moduleNameKebabCase }}'), [$sourcePath]), - '{{ moduleNameKebabCase }}' - ); - - $this->publishes([ - $sourcePath => $viewsPath - ], '{{ moduleNameKebabCase }}-views'); - } -} diff --git a/src/Commands/Generators/stubs/provider/plain.stub b/src/Commands/Generators/stubs/provider/plain.stub deleted file mode 100644 index 537ce0a..0000000 --- a/src/Commands/Generators/stubs/provider/plain.stub +++ /dev/null @@ -1,24 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('{{ webControllerNamespace }}') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/{{ webRoutesPath }}'); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('{{ apiControllerNamespace }}') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/{{ apiRoutesPath }}'); - }); - } -} diff --git a/src/Commands/Generators/stubs/query-wizard/eloquent.stub b/src/Commands/Generators/stubs/query-wizard/eloquent.stub deleted file mode 100644 index a001698..0000000 --- a/src/Commands/Generators/stubs/query-wizard/eloquent.stub +++ /dev/null @@ -1,77 +0,0 @@ - - */ - protected function allowedAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedFields(): array - { - return [ - // TODO: add fields here - ]; - } - - /** - * @return array - */ - protected function allowedFilters(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedSorts(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultSorts(): array - { - return []; - } -} diff --git a/src/Commands/Generators/stubs/query-wizard/model.stub b/src/Commands/Generators/stubs/query-wizard/model.stub deleted file mode 100644 index 2b878ef..0000000 --- a/src/Commands/Generators/stubs/query-wizard/model.stub +++ /dev/null @@ -1,51 +0,0 @@ - - */ - protected function allowedAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedFields(): array - { - return [ - // TODO: add fields here - ]; - } - - /** - * @return array - */ - protected function allowedIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultIncludes(): array - { - return []; - } -} diff --git a/src/Commands/Generators/stubs/request/create.stub b/src/Commands/Generators/stubs/request/create.stub deleted file mode 100644 index 1a2bc08..0000000 --- a/src/Commands/Generators/stubs/request/create.stub +++ /dev/null @@ -1,28 +0,0 @@ -validated()); - } -} diff --git a/src/Commands/Generators/stubs/request/delete.stub b/src/Commands/Generators/stubs/request/delete.stub deleted file mode 100644 index 837e84f..0000000 --- a/src/Commands/Generators/stubs/request/delete.stub +++ /dev/null @@ -1,20 +0,0 @@ -route('{{ modelCamelCase }}'); - return ${{ modelCamelCase }} && Gate::check('delete', ${{ modelCamelCase }}); - } -} diff --git a/src/Commands/Generators/stubs/request/list.stub b/src/Commands/Generators/stubs/request/list.stub deleted file mode 100644 index 8284481..0000000 --- a/src/Commands/Generators/stubs/request/list.stub +++ /dev/null @@ -1,20 +0,0 @@ -route('{{ modelCamelCase }}'); - return ${{ modelCamelCase }} && Gate::check('update', ${{ modelCamelCase }}); - } - - public function toDTO(): {{ dto }} - { - return {{ dto }}::from($this->validated()); - } -} diff --git a/src/Commands/Generators/stubs/request/view.stub b/src/Commands/Generators/stubs/request/view.stub deleted file mode 100644 index e5c4e85..0000000 --- a/src/Commands/Generators/stubs/request/view.stub +++ /dev/null @@ -1,20 +0,0 @@ -route('{{ modelCamelCase }}'); - return ${{ modelCamelCase }} && Gate::check('view', ${{ modelCamelCase }}); - } -} diff --git a/src/Commands/Generators/stubs/resource/collection.stub b/src/Commands/Generators/stubs/resource/collection.stub deleted file mode 100644 index 88f416b..0000000 --- a/src/Commands/Generators/stubs/resource/collection.stub +++ /dev/null @@ -1,17 +0,0 @@ -name('{{ name }}'); diff --git a/src/Commands/Generators/stubs/rule.stub b/src/Commands/Generators/stubs/rule.stub deleted file mode 100755 index e54d7ef..0000000 --- a/src/Commands/Generators/stubs/rule.stub +++ /dev/null @@ -1,19 +0,0 @@ -handle(new {{ createPermissionDTO }}( - name: 'view-{{ modelKebabCase }}', - display_name: 'View any "{{ modelsKebabCase }}"', - group: '{{ modelsKebabCase }}' - )); - - $createPermissionAction->handle(new {{ createPermissionDTO }}( - name: 'create-{{ modelKebabCase }}', - display_name: 'Create "{{ modelsKebabCase }}"', - group: '{{ modelsKebabCase }}' - )); - - $createPermissionAction->handle(new {{ createPermissionDTO }}( - name: 'update-{{ modelKebabCase }}', - display_name: 'Update any "{{ modelsKebabCase }}"', - group: '{{ modelsKebabCase }}' - )); - - $createPermissionAction->handle(new {{ createPermissionDTO }}( - name: 'delete-{{ modelKebabCase }}', - display_name: 'Delete any "{{ modelsKebabCase }}"', - group: '{{ modelsKebabCase }}' - )); - - $createPermissionAction->handle(new {{ createPermissionDTO }}( - name: 'force-delete-{{ modelKebabCase }}', - display_name: 'Force delete any "{{ modelsKebabCase }}"', - group: '{{ modelsKebabCase }}' - )); - } -} diff --git a/src/Commands/Generators/stubs/seeder/plain.stub b/src/Commands/Generators/stubs/seeder/plain.stub deleted file mode 100644 index e8c583f..0000000 --- a/src/Commands/Generators/stubs/seeder/plain.stub +++ /dev/null @@ -1,13 +0,0 @@ - 'create-{{ modelKebabCase }}', - 'roles' => '', - ]; - - public function test_create_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->postJson(route('{{ routeName }}'), $data) - ->assertCreated() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->has('id') - ->whereAll($data) - ->etc() - ) - ); - - $this->assertDatabaseHas({{ model }}::class, $data); - } - - public function test_create_{{ modelSnakeCase }}_with_invalid_data(): void - { - $this->actingAsTestUser(); - - $this->postJson(route('{{ routeName }}'), []) - ->assertUnprocessable() - ->assertJsonValidationErrors([ - // TODO: add expected validation errors here - ]); - } - - public function test_create_{{ modelSnakeCase }}_unauthenticated(): void - { - $data = $this->getTestData(); - - $this->postJson(route('{{ routeName }}'), $data) - ->assertUnauthorized(); - } - - public function test_create_{{ modelSnakeCase }}_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $data = $this->getTestData(); - - $this->postJson(route('{{ routeName }}'), $data) - ->assertForbidden(); - } - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } -} diff --git a/src/Commands/Generators/stubs/test/api/delete.stub b/src/Commands/Generators/stubs/test/api/delete.stub deleted file mode 100644 index ab949bc..0000000 --- a/src/Commands/Generators/stubs/test/api/delete.stub +++ /dev/null @@ -1,61 +0,0 @@ - 'delete-{{ modelKebabCase }}', - 'roles' => '', - ]; - - public function test_delete_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $this->deleteJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()])) - ->assertNoContent(); - - $this->assertNull({{ model }}::find(${{ modelCamelCase }}->getKey())); - } - - public function test_delete_{{ modelSnakeCase }}_unauthenticated(): void - { - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $this->deleteJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()])) - ->assertUnauthorized(); - } - - public function test_delete_{{ modelSnakeCase }}_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $this->deleteJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()])) - ->assertForbidden(); - } - - public function test_delete_not_existing_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - $this->deleteJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => 7777])) - ->assertNotFound(); - } -} diff --git a/src/Commands/Generators/stubs/test/api/list.stub b/src/Commands/Generators/stubs/test/api/list.stub deleted file mode 100644 index d07d8a9..0000000 --- a/src/Commands/Generators/stubs/test/api/list.stub +++ /dev/null @@ -1,57 +0,0 @@ - 'view-{{ modelKebabCase }}', - 'roles' => '', - ]; - - public function test_list_{{ modelsSnakeCase }}(): void - { - $this->actingAsTestUser(); - - {{ model }}::factory()->count(3)->create(); - - $this->getJson(route('{{ routeName }}')) - ->assertOk() - ->assertJsonStructure([ - 'links', - 'meta', - 'data' - ]) - ->assertJsonCount(3, 'data'); - } - - public function test_list_{{ modelsSnakeCase }}_unauthenticated(): void - { - {{ model }}::factory()->count(3)->create(); - - $this->getJson(route('{{ routeName }}')) - ->assertUnauthorized(); - } - - public function test_list_{{ modelsSnakeCase }}_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - {{ model }}::factory()->count(3)->create(); - - $this->getJson(route('{{ routeName }}')) - ->assertForbidden(); - } -} diff --git a/src/Commands/Generators/stubs/test/api/plain.stub b/src/Commands/Generators/stubs/test/api/plain.stub deleted file mode 100644 index f9c09b4..0000000 --- a/src/Commands/Generators/stubs/test/api/plain.stub +++ /dev/null @@ -1,27 +0,0 @@ - '', - 'roles' => '', - ]; - - public function test(): void - { - // - } -} diff --git a/src/Commands/Generators/stubs/test/api/update.stub b/src/Commands/Generators/stubs/test/api/update.stub deleted file mode 100644 index 6ca199f..0000000 --- a/src/Commands/Generators/stubs/test/api/update.stub +++ /dev/null @@ -1,99 +0,0 @@ - 'update-{{ modelKebabCase }}', - 'roles' => '', - ]; - - public function test_update_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $data = $this->getTestData(); - $expectedData = array_merge($data, [ - 'id' => ${{ modelCamelCase }}->getKey(), - ]); - - $this->patchJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()]), $data) - ->assertOk() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->whereAll($expectedData) - ->etc() - ) - ); - - $this->assertDatabaseHas({{ model }}::class, $expectedData); - } - - public function test_update_{{ modelSnakeCase }}_with_invalid_data(): void - { - $this->actingAsTestUser(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $this->patchJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()]), []) - ->assertUnprocessable() - ->assertJsonValidationErrors([ - // TODO: add expected validation errors here - ]); - } - - public function test_update_{{ modelSnakeCase }}_unauthenticated(): void - { - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $data = $this->getTestData(); - - $this->patchJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()]), $data) - ->assertUnauthorized(); - } - - public function test_update_{{ modelSnakeCase }}_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $data = $this->getTestData(); - - $this->patchJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()]), $data) - ->assertForbidden(); - } - - public function test_update_non_existing_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->patchJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => 7777]), $data) - ->assertNotFound(); - } - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } -} diff --git a/src/Commands/Generators/stubs/test/api/view.stub b/src/Commands/Generators/stubs/test/api/view.stub deleted file mode 100644 index 810f24c..0000000 --- a/src/Commands/Generators/stubs/test/api/view.stub +++ /dev/null @@ -1,67 +0,0 @@ - 'view-{{ modelKebabCase }}', - 'roles' => '', - ]; - - public function test_view_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - $expectedData = ${{ modelCamelCase }}->toArray(); - - $this->getJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()])) - ->assertOk() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->whereAll($expectedData) - ->etc() - ) - ); - } - - public function test_view_{{ modelSnakeCase }}_unauthenticated(): void - { - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $this->getJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()])) - ->assertUnauthorized(); - } - - public function test_view_{{ modelSnakeCase }}_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $this->getJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()])) - ->assertForbidden(); - } - - public function test_view_not_existing_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - $this->getJson(route('{{ routeName }}', ['{{ modelCamelCase }}' => 7777])) - ->assertNotFound(); - } -} diff --git a/src/Commands/Generators/stubs/test/cli/plain.stub b/src/Commands/Generators/stubs/test/cli/plain.stub deleted file mode 100644 index b816be1..0000000 --- a/src/Commands/Generators/stubs/test/cli/plain.stub +++ /dev/null @@ -1,16 +0,0 @@ - 'create-{{ modelKebabCase }}', - 'roles' => '', - ]; - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } - - public function test_create_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->post(route('{{ routeName }}'), $data) - ->assertCreated(); - - $this->assertDatabaseHas({{ model }}::class, $data); - } - - public function test_create_{{ modelSnakeCase }}_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $data = $this->getTestData(); - - $this->post(route('{{ routeName }}'), $data) - ->assertForbidden(); - } -} diff --git a/src/Commands/Generators/stubs/test/web/delete.stub b/src/Commands/Generators/stubs/test/web/delete.stub deleted file mode 100644 index 2709649..0000000 --- a/src/Commands/Generators/stubs/test/web/delete.stub +++ /dev/null @@ -1,50 +0,0 @@ - 'delete-{{ modelKebabCase }}', - 'roles' => '', - ]; - - public function test_delete_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $this->delete(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()])) - ->assertNoContent(); - - $this->assertNull({{ model }}::find(${{ modelCamelCase }}->getKey())); - } - - public function test_delete_{{ modelSnakeCase }}_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $this->delete(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()])) - ->assertForbidden(); - } - - public function test_delete_not_existing_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - $this->delete(route('{{ routeName }}', ['{{ modelCamelCase }}' => 7777])) - ->assertNotFound(); - } -} diff --git a/src/Commands/Generators/stubs/test/web/plain.stub b/src/Commands/Generators/stubs/test/web/plain.stub deleted file mode 100644 index ab05743..0000000 --- a/src/Commands/Generators/stubs/test/web/plain.stub +++ /dev/null @@ -1,16 +0,0 @@ - 'update-{{ modelKebabCase }}', - 'roles' => '', - ]; - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } - - public function test_update_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $data = $this->getTestData(); - $expectedData = array_merge($data, [ - 'id' => ${{ modelCamelCase }}->getKey(), - ]); - - $this->patch(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()]), $data) - ->assertOk(); - - $this->assertDatabaseHas({{ model }}::class, $expectedData); - } - - public function test_update_{{ modelSnakeCase }}_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - ${{ modelCamelCase }} = {{ model }}::factory()->create(); - - $data = $this->getTestData(); - - $this->patch(route('{{ routeName }}', ['{{ modelCamelCase }}' => ${{ modelCamelCase }}->getKey()]), $data) - ->assertForbidden(); - } - - public function test_update_non_existing_{{ modelSnakeCase }}(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->patch(route('{{ routeName }}', ['{{ modelCamelCase }}' => 7777]), $data) - ->assertNotFound(); - } -} diff --git a/src/Commands/ListCommand.php b/src/Commands/ListCommand.php deleted file mode 100644 index 67eb3ab..0000000 --- a/src/Commands/ListCommand.php +++ /dev/null @@ -1,38 +0,0 @@ -table( - ['Package Name', 'Namespace', 'Path'], - collect($this->modulesRepository->getModules()) - ->map(fn (Module $module) => [$module->getPackageName(), $module->getNamespace(), $module->getPath()]) - ->values() - ->toArray() - ); - - return self::SUCCESS; - } -} diff --git a/src/Commands/MigrateCommand.php b/src/Commands/MigrateCommand.php deleted file mode 100644 index d6ad43e..0000000 --- a/src/Commands/MigrateCommand.php +++ /dev/null @@ -1,55 +0,0 @@ -call('migrate', [ - '--path' => $this->getMigrationPaths($module), - '--realpath' => (bool) $this->option('realpath'), - '--database' => $this->option('database'), - '--schema-path' => $this->option('schema-path'), - '--pretend' => (bool) $this->option('pretend'), - '--step' => (bool) $this->option('step'), - '--force' => true, - ]); - - if ($this->option('seed') && ! $this->option('pretend')) { - $this->call('module:seed', [ - 'module' => $module->getName(), - '--class' => $this->option('seeder'), - '--force' => true, - ]); - } - } -} diff --git a/src/Commands/MigrateRefreshCommand.php b/src/Commands/MigrateRefreshCommand.php deleted file mode 100644 index 8ce4c2b..0000000 --- a/src/Commands/MigrateRefreshCommand.php +++ /dev/null @@ -1,65 +0,0 @@ -confirmToProceed()) { - return self::FAILURE; - } - - $this->call('module:migrate:reset', [ - 'module' => $this->argument('module'), - '--realpath' => $this->option('realpath'), - '--database' => $this->option('database'), - '--pretend' => $this->option('pretend'), - '--force' => true, - ]); - - $this->call('module:migrate', [ - 'module' => $this->argument('module'), - '--realpath' => $this->option('realpath'), - '--database' => $this->option('database'), - '--schema-path' => $this->option('schema-path'), - '--pretend' => $this->option('pretend'), - '--seed' => $this->option('seed'), - '--seeder' => $this->option('seeder'), - '--step' => $this->option('step'), - '--force' => true, - ]); - - return self::SUCCESS; - } -} diff --git a/src/Commands/MigrateResetCommand.php b/src/Commands/MigrateResetCommand.php deleted file mode 100644 index 248e9dc..0000000 --- a/src/Commands/MigrateResetCommand.php +++ /dev/null @@ -1,41 +0,0 @@ -call('migrate:reset', [ - '--path' => $this->getMigrationPaths($module), - '--database' => $this->option('database'), - '--realpath' => (bool) $this->option('realpath'), - '--pretend' => (bool) $this->option('pretend'), - '--force' => true, - ]); - } -} diff --git a/src/Commands/MigrateRollbackCommand.php b/src/Commands/MigrateRollbackCommand.php deleted file mode 100644 index f3f6499..0000000 --- a/src/Commands/MigrateRollbackCommand.php +++ /dev/null @@ -1,45 +0,0 @@ -call('migrate:rollback', [ - '--path' => $this->getMigrationPaths($module), - '--database' => $this->option('database'), - '--step' => $this->option('step') ?: null, - '--batch' => $this->option('batch') ?: null, - '--realpath' => (bool) $this->option('realpath'), - '--pretend' => (bool) $this->option('pretend'), - '--force' => true, - ]); - } -} diff --git a/src/Commands/MigrateStatusCommand.php b/src/Commands/MigrateStatusCommand.php deleted file mode 100644 index 1af1b41..0000000 --- a/src/Commands/MigrateStatusCommand.php +++ /dev/null @@ -1,44 +0,0 @@ -call('migrate:status', [ - '--path' => $this->getMigrationPaths($module), - '--database' => $this->option('database'), - '--realpath' => (bool) $this->option('realpath'), - '--pending' => (bool) $this->option('pending'), - ]); - } -} diff --git a/src/Commands/ModuleDeleteCommand.php b/src/Commands/ModuleDeleteCommand.php deleted file mode 100644 index b4b7310..0000000 --- a/src/Commands/ModuleDeleteCommand.php +++ /dev/null @@ -1,69 +0,0 @@ -confirmToProceed()) { - return self::FAILURE; - } - - try { - $modulesToDelete = $this->getModuleArgumentOrFail(); - } catch (ModuleNotFound|ModuleHasNonUniquePackageName|ModuleHasNoNamespace $exception) { - $this->error($exception->getMessage()); - - return self::FAILURE; - } - - $exitCode = self::SUCCESS; - - foreach ($modulesToDelete as $moduleToDelete) { - try { - $status = $this->modulesRepository->delete($moduleToDelete->getPackageName(), $this->output); - if ($status) { - $this->components->info("Module [{$moduleToDelete->getPackageName()}] has been deleted."); - } else { - $exitCode = self::FAILURE; - $this->components->error("Failed to remove module [{$moduleToDelete->getPackageName()}]."); - } - } catch (ComposerException $exception) { - $exitCode = self::FAILURE; - $this->components->error($exception->getMessage()); - $this->components->info("Module files were kept. Run composer remove {$moduleToDelete->getPackageName()} manually and retry."); - } catch (CannotDeleteModule $exception) { - $exitCode = self::FAILURE; - $this->components->error($exception->getMessage()); - } - } - - return $exitCode; - } -} diff --git a/src/Commands/StubPublishCommand.php b/src/Commands/StubPublishCommand.php deleted file mode 100644 index 37d6916..0000000 --- a/src/Commands/StubPublishCommand.php +++ /dev/null @@ -1,54 +0,0 @@ -ensureDirectoryExists($customStubsPath = GeneratorHelper::getCustomStubsPath()); - $originalStubsPath = realpath(__DIR__ . '/Generators/stubs'); - - foreach ($filesystem->allFiles($originalStubsPath) as $file) { - $from = $file->getRealPath(); - $relativePath = Str::after($file->getRealPath(), $originalStubsPath); - $to = $customStubsPath . '/' . ltrim($relativePath, '/'); - - $onlyExisting = $this->option('existing'); - $force = $this->option('force'); - $toIsExists = $filesystem->exists($to); - - if (($onlyExisting && $toIsExists) || (! $onlyExisting && (! $toIsExists || $force))) { - $filesystem->ensureDirectoryExists(dirname($to)); - $filesystem->put($to, $filesystem->get($from)); - } - } - - $this->components->info(sprintf('Stubs have been successfully published to the "%s" directory.', $customStubsPath)); - } -} diff --git a/src/Commands/SyncCommand.php b/src/Commands/SyncCommand.php deleted file mode 100644 index 835f634..0000000 --- a/src/Commands/SyncCommand.php +++ /dev/null @@ -1,49 +0,0 @@ -modulesRepository->pruneModulesManifest(); - - try { - $this->modulesRepository->syncWithComposer($this->output); - $this->components->info("Modules completed successfully!"); - } catch (ModuleHasNoNamespace|ModuleHasNonUniquePackageName $e) { - $this->components->error($e->getMessage()); - - return self::FAILURE; - } catch (ComposerException $e) { - $this->components->error($e->getMessage()); - $modulePackageNames = join(" ", array_keys($this->modulesRepository->getModules())); - $this->components->info("Please run composer update {$modulePackageNames} manually"); - - return self::FAILURE; - } - - return self::SUCCESS; - } -} diff --git a/src/Enums/ModuleComponentType.php b/src/Enums/ModuleComponentType.php deleted file mode 100644 index 8d5d485..0000000 --- a/src/Enums/ModuleComponentType.php +++ /dev/null @@ -1,41 +0,0 @@ - - */ -class Module implements Arrayable -{ - use Macroable; - - protected string $packageName; - protected string $name; - protected string $path; - protected string $namespace; - - /** - * @param string $packageName The module package name. - * @param string $name The module name. - * @param string $path The module path. - * @param string $namespace The module namespace. - * @param array $providers Module providers - * @param array $aliases Module aliases - */ - public function __construct( - string $packageName, - string $name, - string $path, - string $namespace, - protected array $providers = [], - protected array $aliases = [], - ) { - $this->packageName = trim($packageName); - $this->name = trim($name) ?: Str::afterLast($this->packageName, '/'); - $this->path = rtrim($path, '/'); - $this->namespace = trim($namespace, '\\'); - } - - /** - * Get package name. - */ - public function getPackageName(): string - { - return $this->packageName; - } - - /** - * Get name. - */ - public function getName(): string - { - return $this->name; - } - - /** - * Get name in studly case. - */ - public function getStudlyName(): string - { - return Str::studly($this->name); - } - - /** - * Get name in kebab case. - */ - public function getKebabName(): string - { - return Str::kebab(str_replace('_', '-', $this->name)); - } - - /** - * Get name in snake case. - */ - public function getSnakeName(): string - { - return Str::snake(str_replace('-', '_', $this->name)); - } - - /** - * Get path. - */ - public function getPath(): string - { - return $this->path; - } - - /** - * Get namespace. - */ - public function getNamespace(): string - { - return $this->namespace; - } - - /** - * Get module providers. - * - * @return array - */ - public function getProviders(): array - { - return $this->providers; - } - - /** - * Get module aliases. - * - * @return array - */ - public function getAliases(): array - { - return $this->aliases; - } - - /** - * Get sub path. - */ - public function subPath(string $subPath): string - { - return $this->getPath() . '/' . GeneratorHelper::normalizePath($subPath); - } - - /** - * Get sub namespace. - */ - public function subNamespace(string $subNamespace): string - { - return $this->getNamespace() . '\\' . GeneratorHelper::normalizeNamespace($subNamespace); - } - - /** - * Handle call __toString. - */ - public function __toString(): string - { - return $this->getPackageName(); - } - - /** - * @return array{ - * path: string, - * packageName: string, - * name: string, - * namespace: string, - * providers: array, - * aliases: array - * } - */ - public function toArray(): array - { - return [ - 'path' => $this->path, - 'packageName' => $this->packageName, - 'name' => $this->name, - 'namespace' => $this->namespace, - 'providers' => $this->providers, - 'aliases' => $this->aliases, - ]; - } -} diff --git a/src/ModulesRepository.php b/src/ModulesRepository.php deleted file mode 100644 index 953d866..0000000 --- a/src/ModulesRepository.php +++ /dev/null @@ -1,406 +0,0 @@ - - */ - protected array $scanPaths = []; - - /** - * The loaded modules array. - * - * @var array|null - */ - protected ?array $modules = null; - - /** - * Create a new module manifest instance. - */ - public function __construct( - protected Filesystem $filesystem, - protected Composer $composer, - protected string $modulesPath, - protected string $basePath, - protected ?string $modulesManifestPath = null, - ) { - $this->addScanPath($this->modulesPath); - } - - /** - * Get module scan paths. - * - * @return array - */ - public function getScanPaths(): array - { - return $this->scanPaths; - } - - /** - * Add module scan path. - * - * @param string|array $scanPaths - * - * @return $this - */ - public function addScanPath(string|array $scanPaths): static - { - foreach (Arr::wrap($scanPaths) as $scanPath) { - $normalizedScanPath = $this->normalizeScanPath($scanPath); - - if (! $normalizedScanPath || in_array($normalizedScanPath, $this->scanPaths, true)) { - continue; - } - - $this->scanPaths[] = $normalizedScanPath; - } - - $this->modules = null; - - return $this; - } - - /** - * Build the modules manifest and write it to disk if cache enabled. - * - * @return array }> - * - * @throws ModuleHasNoNamespace - * @throws ModuleHasNonUniquePackageName - */ - public function buildModulesManifest(): array - { - $modulesManifest = []; - - foreach ($this->scanPaths as $path) { - $packagePaths = $this->filesystem->glob("$path/composer.json"); - - foreach ($packagePaths as $packagePath) { - $packagePath = GeneratorHelper::normalizePath($packagePath, true); - $composerJsonFile = ComposerJsonFile::create($packagePath); - $packageName = trim($composerJsonFile->get('name') ?? ""); - - if (! $packageName) { - continue; - } - - if (array_key_exists($packageName, $modulesManifest)) { - throw ModuleHasNonUniquePackageName::make($packageName); - } - - $path = str_replace('\\', '/', dirname($packagePath)); - - $moduleData = [ - 'path' => $path, - 'name' => basename($path), - 'namespace' => array_key_first($composerJsonFile->get('autoload.psr-4') ?? []), - 'providers' => $composerJsonFile->get('extra.laravel.providers') ?? [], - 'aliases' => $composerJsonFile->get('extra.laravel.aliases') ?? [], - ]; - - $modulesManifest[$packageName] = $this->validateModuleData($packageName, $moduleData); - } - } - - if ($this->modulesManifestPath) { - $this->write($modulesManifest, $this->modulesManifestPath); - } - - return $modulesManifest; - } - - /** - * Prune modules manifest - */ - public function pruneModulesManifest(): bool - { - $this->modules = null; - - if ($this->modulesManifestPath === null) { - return true; - } - - return $this->filesystem->delete($this->modulesManifestPath); - } - - /** - * Get discovered modules - * - * @return array - * - * @throws ModuleHasNoNamespace - * @throws ModuleHasNonUniquePackageName - */ - public function getModules(): array - { - if ($this->modules !== null) { - return $this->modules; - } - - try { - if ($this->modulesManifestPath && $this->filesystem->isFile($this->modulesManifestPath)) { - return $this->modules = $this->makeModulesFromManifest( - $this->filesystem->getRequire($this->modulesManifestPath) - ); - } - } catch (FileNotFoundException) { - // Manifest file not found, will rebuild - } - - return $this->modules = $this->makeModulesFromManifest($this->buildModulesManifest()); - } - - /** - * Determine whether the given module exist by its package name. - */ - public function has(string $modulePackageName): bool - { - return array_key_exists($modulePackageName, $this->getModules()); - } - - /** - * Get count from all modules. - */ - public function count(): int - { - return count($this->getModules()); - } - - /** - * Find a specific module by its package name. - */ - public function find(string $modulePackageName): ?Module - { - $modulePackageName = trim($modulePackageName); - - return $this->getModules()[$modulePackageName] ?? null; - } - - /** - * Find a specific module by its package name, if there return that, otherwise throw exception. - * - * @throws ModuleNotFound - */ - public function findOrFail(string $modulePackageName): Module - { - $module = $this->find($modulePackageName); - - if ($module === null) { - throw ModuleNotFound::make($modulePackageName); - } - - return $module; - } - - /** - * Filter modules by name. - * - * @return array - */ - public function filterByName(string $moduleName): array - { - $moduleName = trim($moduleName); - $moduleKebabName = Str::kebab($moduleName); - $moduleStudlyName = Str::studly($moduleName); - $foundModules = []; - - foreach ($this->getModules() as $modulePackageName => $module) { - $modulePackageNameWithoutVendor = Str::kebab(Str::afterLast($modulePackageName, '/')); - if ($module->getStudlyName() === $moduleStudlyName || $modulePackageNameWithoutVendor === $moduleKebabName) { - $foundModules[$modulePackageName] = $module; - } - } - - return $foundModules; - } - - /** - * Find a specific module by its name, if there return that, otherwise throw exception. - * - * @return array - * - * @throws ModuleNotFound - */ - public function filterByNameOrFail(string $moduleName): array - { - $modules = $this->filterByName($moduleName); - - if (! $modules) { - throw ModuleNotFound::makeForName($moduleName); - } - - return $modules; - } - - /** - * Delete a specific module by its package name. - * - * The package is removed from composer first: if that fails, the module files are kept, - * so composer.json never requires a module that no longer exists locally. - * - * @throws ModuleNotFound - * @throws ComposerException - * @throws CannotDeleteModule - */ - public function delete(string $modulePackageName, \Closure|OutputInterface|null $output = null): bool - { - $module = $this->findOrFail($modulePackageName); - $modulePath = rtrim($module->getPath(), '/\\'); - - if (is_link($modulePath)) { - throw CannotDeleteModule::becauseItIsSymlink($module->getPackageName(), $modulePath); - } - - if (! $this->composer->removePackages([$module->getPackageName()], false, $output)) { - throw ComposerException::make("Failed to remove package with composer."); - } - - $result = $this->filesystem->deleteDirectory($modulePath); - - $this->pruneModulesManifest(); - - return $result; - } - - /** - * Sync modules with composer. - * - * @throws ModuleHasNoNamespace - * @throws ModuleHasNonUniquePackageName - * @throws ComposerException - */ - public function syncWithComposer(\Closure|OutputInterface|null $output = null): void - { - $composerJsonPath = $this->basePath . '/' . Factory::getComposerFile(); - $composerJsonFile = ComposerJsonFile::create($composerJsonPath); - - foreach ($this->getModules() as $modulePackageName => $module) { - $moduleRelativePath = GeneratorHelper::makeRelativePath($this->basePath, $module->getPath()); - if ($moduleRelativePath !== null) { - $composerJsonFile->addModule($modulePackageName, $moduleRelativePath); - } - } - - $composerJsonFile->save(); - - $modulePackageNames = array_keys($this->getModules()); - if (! $modulePackageNames) { - return; - } - - if (! $this->composer->updatePackages($modulePackageNames, false, $output)) { - throw ComposerException::make("Failed to update package with composer."); - } - } - - /** - * @return array, - * aliases: array - * }> - */ - public function toArray(): array - { - return array_map(static fn (Module $module) => $module->toArray(), $this->getModules()); - } - - /** - * Write the given manifest array to disk. - * - * @param array $manifest - * - * @throws DirectoryMustBePresentAndWritable - */ - protected function write(array $manifest, string $manifestPath): void - { - if (! is_writable($dirname = dirname($manifestPath))) { - throw DirectoryMustBePresentAndWritable::make($dirname); - } - - $this->filesystem->replace( - $manifestPath, - ' } $moduleData - * - * @return array{ path: string, name: string, namespace: string, providers: class-string[], aliases: array } - * - * @throws ModuleHasNoNamespace - */ - protected function validateModuleData(string $packageName, array $moduleData): array - { - if (empty(trim($moduleData['namespace'] ?? ""))) { - throw ModuleHasNoNamespace::make($packageName); - } - - return $moduleData; - } - - /** - * Make Module instances from manifest - * - * @param array }> $manifest - * - * @return array - */ - protected function makeModulesFromManifest(array $manifest): array - { - return collect($manifest) - ->map(fn ($module, $packageName) => $this->makeModuleFromManifestItem($packageName, $module)) - ->all(); - } - - /** - * @param string $packageName - * @param array{ path: string, name: string, namespace: string, providers: class-string[], aliases: array } $moduleData - */ - protected function makeModuleFromManifestItem(string $packageName, array $moduleData): Module - { - return new Module( - packageName: $packageName, - name: $moduleData['name'], - path: $moduleData['path'], - namespace: $moduleData['namespace'], - providers: $moduleData['providers'], - aliases: $moduleData['aliases'], - ); - } -} diff --git a/src/Providers/ComposerServiceProvider.php b/src/Providers/ComposerServiceProvider.php deleted file mode 100644 index 1d8ec10..0000000 --- a/src/Providers/ComposerServiceProvider.php +++ /dev/null @@ -1,31 +0,0 @@ -app->singleton(Composer::class, function (Application $app) { - return new Composer($app['files'], $app->basePath()); - }); - } - - /** - * Get the services provided by the provider. - * - * @return array - */ - public function provides(): array - { - return [Composer::class]; - } -} diff --git a/src/Providers/ConsoleServiceProvider.php b/src/Providers/ConsoleServiceProvider.php deleted file mode 100644 index 551ce58..0000000 --- a/src/Providers/ConsoleServiceProvider.php +++ /dev/null @@ -1,74 +0,0 @@ - - */ - protected array $commands = [ - Commands\CacheClearCommand::class, - Commands\CacheCommand::class, - Commands\ListCommand::class, - Commands\MigrateCommand::class, - Commands\MigrateRefreshCommand::class, - Commands\MigrateResetCommand::class, - Commands\MigrateRollbackCommand::class, - Commands\MigrateStatusCommand::class, - Commands\ModuleDeleteCommand::class, - Commands\StubPublishCommand::class, - Commands\SyncCommand::class, - Commands\Generators\ActionMakeCommand::class, - Commands\Generators\CommandMakeCommand::class, - Commands\Generators\ControllerMakeCommand::class, - Commands\Generators\DTOMakeCommand::class, - Commands\Generators\EventMakeCommand::class, - Commands\Generators\ExceptionMakeCommand::class, - Commands\Generators\FactoryMakeCommand::class, - Commands\Generators\JobMakeCommand::class, - Commands\Generators\ListenerMakeCommand::class, - Commands\Generators\MailMakeCommand::class, - Commands\Generators\MiddlewareMakeCommand::class, - Commands\Generators\MigrationMakeCommand::class, - Commands\Generators\ModelMakeCommand::class, - Commands\Generators\ModuleMakeCommand::class, - Commands\Generators\NotificationMakeCommand::class, - Commands\Generators\ObserverMakeCommand::class, - Commands\Generators\PolicyMakeCommand::class, - Commands\Generators\ProviderMakeCommand::class, - Commands\Generators\QueryWizardMakeCommand::class, - Commands\Generators\RequestMakeCommand::class, - Commands\Generators\ResourceMakeCommand::class, - Commands\Generators\RouteMakeCommand::class, - Commands\Generators\RuleMakeCommand::class, - Commands\Generators\SeederMakeCommand::class, - Commands\Generators\TestMakeCommand::class, - ]; - - /** - * Register commands - */ - public function register(): void - { - if ($this->app->runningInConsole()) { - $this->commands(config('modules.commands', $this->commands)); - } - } - - /** - * Get the services provided by the provider. - * - * @return array - */ - public function provides(): array - { - return $this->commands; - } -} diff --git a/src/Providers/ModulesRepositoryServiceProvider.php b/src/Providers/ModulesRepositoryServiceProvider.php deleted file mode 100644 index e8392f8..0000000 --- a/src/Providers/ModulesRepositoryServiceProvider.php +++ /dev/null @@ -1,50 +0,0 @@ -app->singleton(ModulesRepository::class, function (Application $app) { - return new ModulesRepository( - filesystem: $app['files'], - composer: $app[Composer::class], - modulesPath: $app['config']->get('modules.path'), - basePath: $app->basePath(), - modulesManifestPath: $app['config']->get('modules.cache.enabled') - ? $app->bootstrapPath('cache/laraneat-modules.php') - : null - ); - }); - - $this->app->singleton(ModuleConfigWriter::class, function (Application $app) { - return new ModuleConfigWriter( - modulesRepository: $app[ModulesRepository::class], - ); - }); - } - - /** - * Get the services provided by the provider. - * - * @return array - */ - public function provides(): array - { - return [ - ModulesRepository::class, - ModuleConfigWriter::class, - ]; - } -} diff --git a/src/Providers/ModulesServiceProvider.php b/src/Providers/ModulesServiceProvider.php deleted file mode 100755 index 1e62181..0000000 --- a/src/Providers/ModulesServiceProvider.php +++ /dev/null @@ -1,28 +0,0 @@ -mergeConfigFrom(__DIR__ . '/../../config/config.php', 'modules'); - } - - /** - * Bootstrap any application services. - */ - public function boot(): void - { - if (app()->runningInConsole()) { - $this->publishes([ - __DIR__ . '/../../config/config.php' => config_path('modules.php'), - ], 'config'); - } - } -} diff --git a/src/Support/Composer.php b/src/Support/Composer.php deleted file mode 100644 index efa5d9f..0000000 --- a/src/Support/Composer.php +++ /dev/null @@ -1,97 +0,0 @@ - $packages - * @param bool $dev - * @param Closure|OutputInterface|null $output - * @param string|null $composerBinary - * @return bool - * - * @throws ComposerException - */ - public function updatePackages( - array $packages, - bool $dev = false, - Closure|OutputInterface|null $output = null, - ?string $composerBinary = null - ): bool { - return $this->runPackagesCommand('update', $packages, $dev, $output, $composerBinary); - } - - /** - * Remove the given Composer packages from the application. - * - * @param array $packages - * @param bool $dev - * @param Closure|OutputInterface|null $output - * @param string|null $composerBinary - * @return bool - * - * @throws ComposerException - */ - public function removePackages( - array $packages, - bool $dev = false, - Closure|OutputInterface|null $output = null, - $composerBinary = null - ): bool { - return $this->runPackagesCommand('remove', $packages, $dev, $output, $composerBinary); - } - - /** - * @param array $packages - * - * @throws ComposerException - */ - protected function runPackagesCommand( - string $action, - array $packages, - bool $dev, - Closure|OutputInterface|null $output, - ?string $composerBinary - ): bool { - foreach ($packages as $package) { - if (! static::isValidPackageName($package)) { - throw ComposerException::make("Invalid composer package name [{$package}]."); - } - } - - $command = [ - ...$this->findComposer($composerBinary), - $action, - ...($dev ? ['--dev'] : []), - // Everything after "--" is an argument, so a package name can never be read as an option. - '--', - ...$packages, - ]; - - return 0 === $this->getProcess($command, ['COMPOSER_MEMORY_LIMIT' => '-1']) - ->run( - $output instanceof OutputInterface - ? function ($type, $line) use ($output) { - $output->write(' '.$line); - } : $output - ); - } -} diff --git a/src/Support/ComposerJsonFile.php b/src/Support/ComposerJsonFile.php deleted file mode 100644 index 2f2552a..0000000 --- a/src/Support/ComposerJsonFile.php +++ /dev/null @@ -1,215 +0,0 @@ -composerJsonHandler = new JsonFile($filePath); - } - - public static function create( - string $filePath - ): static { - return new static($filePath); - } - - /** - * Get an item from a json content using "dot" notation. - * - * @param string $key - * @param mixed $default - * @return mixed - */ - public function get(string $key, mixed $default = null): mixed - { - return Arr::get($this->read(), $key, $default); - } - - /** - * Set JSON content item to a given value using "dot" notation. - * - * @param string $key - * @param mixed $value - * @return $this - */ - public function set(string $key, mixed $value): static - { - $this->read(); - Arr::set($this->jsonContent, $key, $value); - - return $this; - } - - public function addModule(string $modulePackageName, string $moduleRelativePath): static - { - $this->addPathRepository(dirname($moduleRelativePath)); - $this->excludeFromPackagist(Str::before($modulePackageName, '/') . '/*'); - - $packages = $this->get('require'); - - if (! isset($packages[$modulePackageName])) { - $packages[$modulePackageName] = '*'; - $this->set('require', $this->sortPackages($packages)); - } - - return $this; - } - - public function addPathRepository(string $path, array $options = ['symlink' => true]): static - { - $path = Str::finish(GeneratorHelper::normalizePath($path, true), '/*'); - - $repositories = $this->get('repositories', []); - $repositoryAlreadyExists = collect($repositories) - ->contains(fn ($repository) => is_array($repository) && ($repository['url'] ?? null) === $path); - - if ($repositoryAlreadyExists) { - return $this; - } - - $repositories[] = [ - 'type' => 'path', - 'url' => $path, - 'options' => $options, - ]; - - $this->set('repositories', $repositories); - - return $this; - } - - /** - * Never resolve the given package pattern (e.g. "app/*") from Packagist. - * - * Modules are required with "*" from a path repository. If a module directory is missing - * (deleted module, other branch, CI without the modules directory), Composer would otherwise - * fall back to Packagist and could install a foreign package with the same name. - */ - public function excludeFromPackagist(string $packagePattern): static - { - $repositories = $this->get('repositories', []); - - foreach ($repositories as $key => $repository) { - if (is_array($repository) && $this->isPackagistRepository($repository)) { - if (! isset($repository['only']) && ! in_array($packagePattern, $repository['exclude'] ?? [], true)) { - $repositories[$key]['exclude'] = [...($repository['exclude'] ?? []), $packagePattern]; - $this->set('repositories', $repositories); - } - - return $this; - } - } - - if ($this->isPackagistDisabled($repositories)) { - return $this; - } - - $packagist = ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => [$packagePattern]]; - - if (array_is_list($repositories)) { - $repositories[] = $packagist; - $repositories[] = ['packagist.org' => false]; - } else { - $repositories['packagist'] = $packagist; - $repositories['packagist.org'] = false; - } - - $this->set('repositories', $repositories); - - return $this; - } - - /** - * @param array $repository - */ - protected function isPackagistRepository(array $repository): bool - { - return ($repository['type'] ?? null) === 'composer' - && in_array(rtrim((string) ($repository['url'] ?? ''), '/'), ['https://repo.packagist.org', 'https://packagist.org'], true); - } - - /** - * @param array $repositories - */ - protected function isPackagistDisabled(array $repositories): bool - { - foreach ($repositories as $key => $repository) { - if (($key === 'packagist.org' && $repository === false) - || (is_array($repository) && ($repository['packagist.org'] ?? null) === false)) { - return true; - } - } - - return false; - } - - /** - * Save the JSON content to a file - * - * @throws \Exception - */ - public function save(int $flags = JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE): void - { - if ($this->jsonContent !== null) { - $this->composerJsonHandler->write($this->jsonContent, $flags); - } - } - - protected function read(): array - { - if ($this->jsonContent !== null) { - return $this->jsonContent; - } - - return $this->jsonContent = $this->composerJsonHandler->read(); - } - - /** - * @param array $packages - * @return array - */ - protected function sortPackages(array $packages): array - { - $prefix = fn ($requirement) - => preg_replace( - [ - '/^php$/', - '/^hhvm-/', - '/^ext-/', - '/^lib-/', - '/^\D/', - '/^(?!php$|hhvm-|ext-|lib-)/', - ], - [ - '0-$0', - '1-$0', - '2-$0', - '3-$0', - '4-$0', - '5-$0', - ], - $requirement - ); - - uksort($packages, function ($a, $b) use ($prefix) { - return strnatcmp($prefix($a), $prefix($b)); - }); - - return $packages; - } -} diff --git a/src/Support/Concerns/CanLoadRoutesFromDirectory.php b/src/Support/Concerns/CanLoadRoutesFromDirectory.php deleted file mode 100644 index 658d5af..0000000 --- a/src/Support/Concerns/CanLoadRoutesFromDirectory.php +++ /dev/null @@ -1,55 +0,0 @@ -loadRoutesFromDirectory( - $nestedDirectory, - $directoryRoutePrefix, - $generateRoutePrefixesByNestedDirectories - ); - } - - // Only "*.php" files are routes: leftovers like "*.php.orig" or "README.md" must never be required. - /** @var SplFileInfo[] $files */ - $files = Arr::sort( - array_filter(File::files($directory), static fn (SplFileInfo $file) => $file->getExtension() === 'php'), - static fn (SplFileInfo $file) => $file->getFilename() - ); - - Route::prefix($routePrefix)->group(function () use ($files) { - foreach ($files as $file) { - require $file->getPathname(); - } - }); - } -} diff --git a/src/Support/Concerns/CanRunModuleSeeders.php b/src/Support/Concerns/CanRunModuleSeeders.php deleted file mode 100644 index 9bd39c5..0000000 --- a/src/Support/Concerns/CanRunModuleSeeders.php +++ /dev/null @@ -1,158 +0,0 @@ -getSeederClassesFromModule($module, $subdirectories); - } - - $seederClasses = $this->sortSeederClasses(Arr::flatten($seederClasses)); - foreach ($seederClasses as $seeder) { - $this->call($seeder); - } - } - - /** - * Seeders run in the order of their integer "_N" suffix (e.g. "PermissionsSeeder_1"), - * seeders without such a suffix run last; ties are ordered by class name. - */ - protected function sortSeederClasses(array $seedersClasses): array - { - $order = static function (string $seederClass): int { - $suffix = Str::afterLast(class_basename($seederClass), '_'); - - return $suffix !== class_basename($seederClass) && ctype_digit($suffix) ? (int) $suffix : PHP_INT_MAX; - }; - - usort($seedersClasses, static fn (string $a, string $b): int => [$order($a), $a] <=> [$order($b), $b]); - - return $seedersClasses; - } - - protected function getSeederClassesFromModule(Module $module, array $subdirectories = []): array - { - $moduleSeedersPath = rtrim(GeneratorHelper::component(ModuleComponentType::Seeder)->getFullPath($module), '/'); - $paths = array_unique([ - $moduleSeedersPath, - ...array_map( - static fn ($subdirectory) => rtrim($moduleSeedersPath . '/' . trim($subdirectory, '/'), '/'), - $subdirectories - ), - ]); - - $seederClasses = []; - foreach ($paths as $path) { - if (File::isDirectory($path)) { - foreach (File::files($path) as $file) { - if ($file->getExtension() === 'php') { - $seederClasses[] = $this->getClassFullNameFromFile($file->getPathname()); - } - } - } - } - - return $seederClasses; - } - - /** - * Get the full name (name \ namespace) of a class from its file path - * result example: (string) "I\Am\The\Namespace\Of\This\Class" - * - * @param string $filePathName - * - * @return string - */ - public function getClassFullNameFromFile(string $filePathName): string - { - return $this->getClassNamespaceFromFile($filePathName) . '\\' . $this->getClassNameFromFile($filePathName); - } - - /** - * Get the class namespace form file path using token - * - * @param string $filePathName - * - * @return null|string - */ - protected function getClassNamespaceFromFile(string $filePathName): ?string - { - $src = file_get_contents($filePathName); - - $tokens = token_get_all($src); - $count = count($tokens); - $i = 0; - $namespace = ''; - $namespace_ok = false; - while ($i < $count) { - $token = $tokens[$i]; - if (is_array($token) && $token[0] === T_NAMESPACE) { - // Found namespace declaration - while (++$i < $count) { - if ($tokens[$i] === ';') { - $namespace_ok = true; - $namespace = trim($namespace); - - break; - } - $namespace .= is_array($tokens[$i]) ? $tokens[$i][1] : $tokens[$i]; - } - - break; - } - $i++; - } - if (! $namespace_ok) { - return null; - } - - return $namespace; - } - - /** - * Get the class name from file path using token - * - * @param string $filePathName - * - * @return string - */ - protected function getClassNameFromFile(string $filePathName): string - { - $php_code = file_get_contents($filePathName); - - $classes = []; - $tokens = token_get_all($php_code); - $count = count($tokens); - for ($i = 2; $i < $count; $i++) { - if ($tokens[$i - 2][0] === T_CLASS - && $tokens[$i - 1][0] === T_WHITESPACE - && $tokens[$i][0] === T_STRING - ) { - $class_name = $tokens[$i][1]; - $classes[] = $class_name; - } - } - - return $classes[0]; - } -} diff --git a/src/Support/Concerns/InteractsWithTestUser.php b/src/Support/Concerns/InteractsWithTestUser.php deleted file mode 100644 index c044c0a..0000000 --- a/src/Support/Concerns/InteractsWithTestUser.php +++ /dev/null @@ -1,186 +0,0 @@ -|null - */ - protected ?string $testUserClass = null; - - /** - * Roles and permissions, to be attached on the user by default - * - * @var array{permissions?: string|array, roles?:string|array} - */ - protected array $testUserAccess = [ - 'permissions' => '', - 'roles' => '', - ]; - - protected ?string $testUserGuard = null; - - /** - * Create a test user without access and make him logged into the application. - * Same as `besTestUser()` but always overrides the user $access - * (roles and permissions) with null. So the user can be used to test - * if unauthorized user tried to access your protected endpoint. - * - * @param array|null $userDetails - * @param string|null $guard - * - * @return $this - */ - public function beTestUserWithoutAccess(?array $userDetails = null, ?string $guard = null): static - { - return $this->actingAsTestUserWithoutAccess($userDetails, $guard); - } - - /** - * Create a test user and make him logged into the application. - * - * @param array|null $userDetails - * @param array{permissions?: string|array, roles?:string|array}|null $access - * @param string|null $guard - * - * @return $this - */ - public function beTestUser(?array $userDetails = null, ?array $access = null, ?string $guard = null): static - { - return $this->actingAsTestUser($userDetails, $access, $guard); - } - - /** - * Create a test user without access and make him logged into the application. - * Same as `actingAsTestUser()` but always overrides the user $access - * (roles and permissions) with null. So the user can be used to test - * if unauthorized user tried to access your protected endpoint. - * - * @param array|null $userDetails - * @param string|null $guard - * - * @return $this - */ - public function actingAsTestUserWithoutAccess(?array $userDetails = null, ?string $guard = null): static - { - return $this->actingAs( - $this->createTestUserWithoutAccess($userDetails), - $guard ?? $this->testUserGuard - ); - } - - /** - * Create a test user and make him logged into the application. - * - * @param array|null $userDetails - * @param array{permissions?: string|array, roles?:string|array}|null $access - * @param string|null $guard - * - * @return $this - */ - public function actingAsTestUser(?array $userDetails = null, ?array $access = null, ?string $guard = null): static - { - return $this->actingAs( - $this->createTestUser($userDetails, $access), - $guard ?? $this->testUserGuard - ); - } - - /** - * Create test user without access. - * Same as `createTestUser()` but always overrides the user $access - * (roles and permissions) with null. So the user can be used to test - * if unauthorized user tried to access your protected endpoint. - * - * @param array|null $userDetails - * @return U - */ - public function createTestUserWithoutAccess(?array $userDetails = null): UserContract - { - return $this->createTestUser($userDetails, [ - 'permissions' => null, - 'roles' => null, - ]); - } - - /** - * Create test user. - * By default, Users will be given the Roles and Permissions found in the class - * `$access` property. But the $access parameter can be used to override the - * defined roles and permissions in the `$access` property of your class. - * - * @param array|null $userDetails - * @param array{permissions?: string|array, roles?:string|array}|null $access - * @return U - */ - public function createTestUser(?array $userDetails = null, ?array $access = null): UserContract - { - $this->testUserClass = $this->testUserClass ?? config('modules.user_model'); - - if (! $this->testUserClass) { - throw new LogicException("User class was not provided!"); - } - - return $this->setupTestUserAccess( - $this->factoryCreateUser($userDetails), - $access - ); - } - - /** - * @param array|null $userDetails - * @return U - */ - private function factoryCreateUser(?array $userDetails = null): UserContract - { - if (! method_exists($this->testUserClass, 'factory')) { - throw new LogicException("class `$this->testUserClass` does not have method `factory()`"); - } - - return $this->testUserClass::factory()->create($this->prepareUserDetails($userDetails)); - } - - private function prepareUserDetails(?array $userDetails = null): array - { - $defaultUserDetails = [ - 'name' => 'Testing user', - 'email' => 'testing@test.com', - 'password' => 'testing-password', - ]; - $userDetails = $userDetails ? array_merge($defaultUserDetails, $userDetails) : $defaultUserDetails; - - $userDetails['password'] = Hash::make($userDetails['password']); - - return $userDetails; - } - - private function setupTestUserAccess(UserContract $user, ?array $access = null): UserContract - { - $access = $access ?: $this->testUserAccess; - - if ($access['permissions'] ?? false) { - if (! method_exists($user, 'givePermissionTo')) { - throw new LogicException("user instance does not have method `givePermissionTo()`, make sure the user class uses `spatie/laravel-permission`"); - } - $user->givePermissionTo($access['permissions']); - } - - if ($access['roles'] ?? false) { - if (! method_exists($user, 'assignRole')) { - throw new LogicException("user instance does not have method `assignRole()`, make sure the user class uses `spatie/laravel-permission`"); - } - $user->assignRole($access['roles']); - } - - return $user; - } -} diff --git a/src/Support/Concerns/WithJsonResponseHelpers.php b/src/Support/Concerns/WithJsonResponseHelpers.php deleted file mode 100644 index 86ab197..0000000 --- a/src/Support/Concerns/WithJsonResponseHelpers.php +++ /dev/null @@ -1,33 +0,0 @@ - getScanPaths() - * @method static $this addScanPath(string|string[] $scanPaths) - * @method static array buildModulesManifest() - * @method static bool pruneModulesManifest() - * @method static array getModules() - * @method static array getPackagesToIgnore() - * @method static bool has(string $modulePackageName) - * @method static int count() - * @method static Module|null find(string $modulePackageName) - * @method static Module findOrFail(string $modulePackageName) - * @method static array filterByName(string $moduleName) - * @method static array filterByNameOrFail(string $moduleName) - * @method static bool delete(string $modulePackageName, \Closure|OutputInterface|null $output = null) - * @method static void syncWithComposer(\Closure|OutputInterface|null $output = null) - * @method static array toArray() - * - * @see \Laraneat\Modules\ModulesRepository - */ -class Modules extends Facade -{ - /** - * Get the registered name of the component. - */ - protected static function getFacadeAccessor(): string - { - return ModulesRepository::class; - } -} diff --git a/src/Support/Generator/GeneratorHelper.php b/src/Support/Generator/GeneratorHelper.php deleted file mode 100755 index dc60a4c..0000000 --- a/src/Support/Generator/GeneratorHelper.php +++ /dev/null @@ -1,214 +0,0 @@ -value}"; - $generatorComponent = config($configPath); - - if (! is_array($generatorComponent) || empty($generatorComponent['path'])) { - throw InvalidConfigValue::make($configPath); - }; - - return new GeneratorPath($generatorComponent); - } - - /** - * Get module path - * - * @throws InvalidConfigValue - */ - public static function makeModulePath(Module|string $moduleOrName, ?string $subPath = null): ?string - { - if ($moduleOrName instanceof Module) { - return $moduleOrName->subPath($subPath); - } - - $modulePart = self::normalizePath($moduleOrName); - - if (! $modulePart) { - return null; - } - - $modulePath = self::getBasePath() . '/' . $modulePart; - - return $subPath ? $modulePath . '/' . self::normalizePath($subPath) : $modulePath; - } - - /** - * Get module namespace - * - * @throws InvalidConfigValue - */ - public static function makeModuleNamespace(Module|string $moduleOrName, ?string $subNamespace = null): ?string - { - if ($moduleOrName instanceof Module) { - return $moduleOrName->subNamespace($subNamespace); - } - - $modulePart = self::normalizeNamespace(Str::studly($moduleOrName)); - - if (! $modulePart) { - return null; - } - - $moduleNamespace = self::getBaseNamespace() . '\\' . $modulePart; - - return $subNamespace ? $moduleNamespace . '\\' . self::normalizeNamespace($subNamespace) : $moduleNamespace; - } - - /** - * Make relative path or returns null - */ - public static function makeRelativePath(string $from, string $to): ?string - { - $from = static::normalizePath($from, true); - $to = static::normalizePath($to, true); - - if ($from === $to) { - return ''; - } - - $fromSegments = explode('/', $from); - $toSegments = explode('/', $to); - - $diffStartIndex = null; - $fromSegmentsCount = count($fromSegments); - $toSegmentsCount = count($toSegments); - $segmentsCount = max($fromSegmentsCount, $toSegmentsCount); - for ($i = 0; $i < $segmentsCount; $i++) { - if (! isset($fromSegments[$i], $toSegments[$i]) || $fromSegments[$i] !== $toSegments[$i]) { - if ($i === 0 || $i === 1 && ! $fromSegments[0]) { - return null; - } - $diffStartIndex = $i; - - break; - } - } - - $relativePath = Str::repeat('../', $fromSegmentsCount - $diffStartIndex) - . join('/', array_slice($toSegments, $diffStartIndex)); - - return rtrim($relativePath, '/'); - } - - /** - * Normalize path to use only forward slash and trim slashes - */ - public static function normalizePath(string $path, $useRtrim = false): string - { - $path = str_replace('\\', '/', $path); - - return $useRtrim && Str::startsWith($path, '/') - ? '/' . trim($path, '/') - : trim($path, '/'); - } - - /** - * Normalize namespace to use only backslash and trim slashes - */ - public static function normalizeNamespace(string $namespace, $useRtrim = false): string - { - $namespace = str_replace('/', '\\', $namespace); - - return $useRtrim && Str::startsWith($namespace, '\\') - ? '\\' . trim($namespace, '\\') - : trim($namespace, '\\'); - } -} diff --git a/src/Support/Generator/GeneratorPath.php b/src/Support/Generator/GeneratorPath.php deleted file mode 100755 index f522389..0000000 --- a/src/Support/Generator/GeneratorPath.php +++ /dev/null @@ -1,37 +0,0 @@ -path = GeneratorHelper::normalizePath((string) $config['path']); - $this->namespace = GeneratorHelper::normalizeNamespace($config['namespace'] ?? $this->path); - } - - public function getPath(): string - { - return $this->path; - } - - public function getFullPath(Module|string $module): string - { - return GeneratorHelper::makeModulePath($module, $this->path); - } - - public function getNamespace(): string - { - return $this->namespace; - } - - public function getFullNamespace(Module|string $module): string - { - return GeneratorHelper::makeModuleNamespace($module, $this->namespace); - } -} diff --git a/src/Support/Generator/Stub.php b/src/Support/Generator/Stub.php deleted file mode 100755 index 523ca75..0000000 --- a/src/Support/Generator/Stub.php +++ /dev/null @@ -1,120 +0,0 @@ - - */ - protected array $replaces = []; - - /** - * @param string $path - * @param array $replaces - */ - public function __construct(string $path, array $replaces = []) - { - $this->path = $path; - $this->replaces = $replaces; - } - - /** - * Create new self instance. - * - * @param string $path - * @param array $replaces - * - * @return Stub - */ - public static function create(string $path, array $replaces = []): Stub - { - return new static($path, $replaces); - } - - /** - * Set stub path. - * - * @param string $path - * - * @return $this - */ - public function setPath(string $path): static - { - $this->path = $path; - - return $this; - } - - /** - * Get stub path. - */ - public function getPath(): string - { - $customStubsFolderPath = GeneratorHelper::getCustomStubsPath(); - $customStubFilePath = $customStubsFolderPath . '/' . ltrim($this->path, '/'); - - if (file_exists($customStubFilePath)) { - return $customStubFilePath; - } - - return __DIR__ . '/../../Commands/Generators/stubs/' . ltrim($this->path, '/'); - } - - /** - * Set replacements array. - * - * @param array $replaces - * - * @return $this - */ - public function setReplaces(array $replaces = []): static - { - $this->replaces = $replaces; - - return $this; - } - - /** - * Get replacements. - * - * @return array - */ - public function getReplaces(): array - { - return $this->replaces; - } - - /** - * Render stub contents. - */ - public function render(): string - { - $contents = file_get_contents($this->getPath()); - - foreach ($this->replaces as $search => $replace) { - $contents = str_replace( - ['{{' . $search . '}}', '{{ ' . $search . ' }}'], - $replace, - $contents - ); - } - - return $contents; - } - - /** - * Handle magic method __toString. - */ - public function __toString() - { - return $this->render(); - } -} diff --git a/src/Support/Migrations/NameParser.php b/src/Support/Migrations/NameParser.php deleted file mode 100755 index 84c656f..0000000 --- a/src/Support/Migrations/NameParser.php +++ /dev/null @@ -1,141 +0,0 @@ - [ - 'create', - 'make', - ], - 'delete' => [ - 'delete', - 'remove', - ], - 'add' => [ - 'add', - 'update', - 'append', - 'insert', - ], - ]; - - /** - * The constructor. - */ - public function __construct(string $name) - { - $this->name = $name; - $this->data = $this->fetchData(); - } - - /** - * Get original migration name. - */ - public function getOriginalName(): string - { - return $this->name; - } - - /** - * Get schema type or action. - */ - public function getAction(): string - { - return head($this->data); - } - - /** - * Get the table will be used. - */ - public function getTableName(): ?string - { - $matches = array_reverse($this->getMatches()); - - return array_shift($matches); - } - - /** - * Get matches data from regex. - */ - public function getMatches(): array - { - preg_match($this->getPattern(), $this->name, $matches); - - return $matches; - } - - /** - * Get name pattern. - */ - public function getPattern(): string - { - return match ($action = $this->getAction()) { - 'add', 'append', 'update', 'insert' => "/{$action}_(.*)_to_(.*)_table/", - 'delete', 'remove', 'alter' => "/{$action}_(.*)_from_(.*)_table/", - default => "/{$action}_(.*)_table/", - }; - } - - /** - * Fetch the migration name to an array data. - */ - protected function fetchData(): array - { - return explode('_', $this->name); - } - - /** - * Get the array data. - */ - public function getData(): array - { - return $this->data; - } - - /** - * Determine whether the given type is same with the current schema action or type. - */ - public function is(string $type): bool - { - return $type === $this->getAction(); - } - - /** - * Determine whether the current schema action is a adding action. - */ - public function isAdd(): bool - { - return in_array($this->getAction(), $this->actions['add'], true); - } - - /** - * Determine whether the current schema action is a deleting action. - */ - public function isDelete(): bool - { - return in_array($this->getAction(), $this->actions['delete'], true); - } - - /** - * Determine whether the current schema action is a creating action. - */ - public function isCreate(): bool - { - return in_array($this->getAction(), $this->actions['create'], true); - } -} diff --git a/src/Support/Migrations/SchemaParser.php b/src/Support/Migrations/SchemaParser.php deleted file mode 100755 index 514ab59..0000000 --- a/src/Support/Migrations/SchemaParser.php +++ /dev/null @@ -1,244 +0,0 @@ - 'rememberToken()', - 'soft_delete' => 'softDeletes()', - ]; - - /** - * The migration schema. - */ - protected ?string $schema; - - /** - * The relationship keys. - */ - protected array $relationshipKeys = [ - 'belongsTo', - ]; - - /** - * Create new instance. - */ - public function __construct(?string $schema = null) - { - $this->schema = $schema; - } - - /** - * Parse a string to array of formatted schema. - */ - public function parse(?string $schema): array - { - $this->schema = $schema; - - $parsed = []; - - foreach ($this->getSchemas() as $schemaArray) { - $column = $this->getColumn($schemaArray); - - $attributes = $this->getAttributes($column, $schemaArray); - - $parsed[$column] = $attributes; - } - - return $parsed; - } - - /** - * Get array of schema. - */ - public function getSchemas(): array - { - if (is_null($this->schema)) { - return []; - } - - return explode(',', str_replace(' ', '', $this->schema)); - } - - /** - * Convert string migration to array. - */ - public function toArray(): array - { - return $this->parse($this->schema); - } - - /** - * Render the migration to formatted script. - */ - public function render(): string - { - $results = ''; - - foreach ($this->toArray() as $column => $attributes) { - $results .= $this->createField($column, $attributes); - } - - return $results; - } - - /** - * Render up migration fields. - */ - public function up(): string - { - return $this->render(); - } - - /** - * Render down migration fields. - */ - public function down(): string - { - $results = ''; - - foreach ($this->toArray() as $column => $attributes) { - $results .= $this->createField($column, $attributes, 'remove'); - } - - return $results; - } - - /** - * Create field. - */ - public function createField(string $column, array $attributes, string $type = 'add'): string - { - $results = "\t\t\t" . '$table'; - - if (in_array($column, $this->relationshipKeys, true)) { - if ($type === 'add') { - $results .= $this->addRelationColumn($attributes, $column); - } elseif ($type === 'remove') { - $results .= $this->removeRelationColumn($attributes, $column); - } - } else { - if ($type === 'remove') { - $attributes = [head($attributes)]; - } - foreach ($attributes as $key => $field) { - $results .= $this->{"{$type}Column"}($key, $field, $column); - } - } - - return $results . ';' . PHP_EOL; - } - - /** - * Add relation column. - */ - protected function addRelationColumn(array $attributes, string $column): string - { - $result = ''; - - foreach ($attributes as $key => $field) { - if ($key === 0) { - $relatedColumn = Str::snake(class_basename($field)) . '_id'; - $result .= "->integer('$relatedColumn')->unsigned();" . PHP_EOL . "\t\t\t" . "\$table->foreign('$relatedColumn')"; - } elseif ($key === 1) { - $result .= "->references('$field')"; - } elseif ($key === 2) { - $result .= "->on('$field')"; - } elseif (Str::contains($field, '(')) { - $result .= '->' . $field; - } else { - $result .= '->' . $field . '()'; - } - } - - return $result; - } - - /** - * Remove relation column. - */ - protected function removeRelationColumn(array $attributes, string $column): string - { - if (! ($attributes[0] ?? null)) { - return ""; - } - - $relatedColumn = Str::snake(class_basename($attributes[0])) . '_id'; - - return "->dropColumn('$relatedColumn');" . PHP_EOL . "\t\t\t" . "\$table->dropForeign(['$relatedColumn'])"; - } - - /** - * Format field to script. - */ - protected function addColumn(int $key, string $field, string $column): string - { - if ($this->hasCustomAttribute($column)) { - return '->' . $field; - } - - if ($key === 0) { - return '->' . $field . "('" . $column . "')"; - } - - if (Str::contains($field, '(')) { - return '->' . $field; - } - - return '->' . $field . '()'; - } - - /** - * Format field to script. - */ - protected function removeColumn(int $key, string $field, string $column): string - { - if ($this->hasCustomAttribute($column)) { - return '->' . $field; - } - - return '->dropColumn(' . "'" . $column . "')"; - } - - /** - * Get column name from schema. - */ - public function getColumn(string $schema): string - { - return Arr::get(explode(':', $schema), 0); - } - - /** - * Get column attributes. - */ - public function getAttributes(string $column, string $schema): array - { - $fields = str_replace($column . ':', '', $schema); - - return $this->hasCustomAttribute($column) ? $this->getCustomAttribute($column) : explode(':', $fields); - } - - /** - * Determine whether the given column exists in the customAttributes array. - */ - public function hasCustomAttribute(string $column): bool - { - return array_key_exists($column, $this->customAttributes); - } - - /** - * Get custom attributes value. - */ - public function getCustomAttribute(string $column): array - { - return (array) $this->customAttributes[$column]; - } -} diff --git a/src/Support/ModuleConfigWriter.php b/src/Support/ModuleConfigWriter.php deleted file mode 100644 index 45c0825..0000000 --- a/src/Support/ModuleConfigWriter.php +++ /dev/null @@ -1,96 +0,0 @@ - $providers - * - * @throws FileNotFoundException - */ - public function updateProviders(Module $module, array $providers): void - { - $this->updateConfig($module, 'extra.laravel.providers', $providers); - } - - /** - * Update module aliases in composer.json - * - * @param Module $module - * @param array $aliases - * - * @throws FileNotFoundException - */ - public function updateAliases(Module $module, array $aliases): void - { - $this->updateConfig($module, 'extra.laravel.aliases', $aliases); - } - - /** - * Add a provider to the module - * - * @param Module $module - * @param class-string $providerClass - * - * @throws FileNotFoundException - */ - public function addProvider(Module $module, string $providerClass): void - { - $providers = $module->getProviders(); - - if (! in_array($providerClass, $providers, true)) { - $providers[] = $providerClass; - $this->updateProviders($module, $providers); - } - } - - /** - * Add an alias to the module - * - * @param Module $module - * @param string $alias - * @param class-string $class - * - * @throws FileNotFoundException - */ - public function addAlias(Module $module, string $alias, string $class): void - { - $aliases = $module->getAliases(); - - if (! isset($aliases[$alias]) || $aliases[$alias] !== $class) { - $aliases[$alias] = $class; - $this->updateAliases($module, $aliases); - } - } - - /** - * Update a config value in module's composer.json - * - * @param Module $module - * @param string $key - * @param mixed $value - * - * @throws FileNotFoundException|\Exception - */ - protected function updateConfig(Module $module, string $key, mixed $value): void - { - ComposerJsonFile::create($module->getPath() . '/composer.json') - ->set($key, $value) - ->save(); - - $this->modulesRepository->pruneModulesManifest(); - } -} diff --git a/src/Support/ModuleServiceProvider.php b/src/Support/ModuleServiceProvider.php deleted file mode 100644 index c72db3e..0000000 --- a/src/Support/ModuleServiceProvider.php +++ /dev/null @@ -1,111 +0,0 @@ - $pathsByNamespace - * - * @throws ReflectionException - */ - protected function loadCommandsFrom( - array $pathsByNamespace, - ): void { - $pathsByNamespace = array_unique(Arr::wrap($pathsByNamespace)); - $pathsByNamespace = array_filter($pathsByNamespace, static function ($path) { - return is_dir($path); - }); - - if (empty($pathsByNamespace)) { - return; - } - - foreach ($pathsByNamespace as $namespace => $path) { - foreach (Finder::create()->in($path)->files() as $file) { - $command = $this->commandClassFromFile($file, $path, $namespace); - - if ( - is_subclass_of($command, Command::class) && - ! (new ReflectionClass($command))->isAbstract() - ) { - Artisan::starting(function ($artisan) use ($command) { - $artisan->resolve($command); - }); - } - } - } - } - - /** - * Extract the command class name from the given file path. - */ - protected function commandClassFromFile( - SplFileInfo $file, - string $basePath, - string $baseNamespace - ): string { - return rtrim($baseNamespace, '\\') . '\\' . str_replace( - ['/', '.php'], - ['\\', ''], - Str::after($file->getRealPath(), realpath($basePath) . DIRECTORY_SEPARATOR) - ); - } - - /** - * Load files from directory - */ - protected function loadFiles(string $directory): void - { - if (File::isDirectory($directory)) { - foreach (File::files($directory) as $file) { - if ($file->getExtension() === 'php') { - require_once $file; - } - } - } - } - - /** - * Load all files from directory - */ - protected function loadAllFiles(string $directory): void - { - if (File::isDirectory($directory)) { - foreach (File::allFiles($directory) as $file) { - if ($file->getExtension() === 'php') { - require_once $file; - } - } - } - } -} diff --git a/tests/Commands/BaseCommandTest.php b/tests/Commands/BaseCommandTest.php deleted file mode 100644 index 63f62ea..0000000 --- a/tests/Commands/BaseCommandTest.php +++ /dev/null @@ -1,513 +0,0 @@ -setModules([ - __DIR__ . '/../fixtures/stubs/modules/valid/article-category', - __DIR__ . '/../fixtures/stubs/modules/valid/article', - __DIR__ . '/../fixtures/stubs/modules/valid/author', - __DIR__ . '/../fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/../fixtures/stubs/modules/valid/empty', - __DIR__ . '/../fixtures/stubs/modules/valid/navigation', - ], $this->app->basePath('/modules')); -}); - -describe('single "module" argument', function () { - class CommandWithSingleModuleArgument extends BaseCommand - { - /** - * The name and signature of the console command. - * - * @var string - */ - protected $signature = 'single-module-argument-command {module? : Module name}'; - - /** - * Execute the console command. - */ - public function handle(): int - { - try { - $moduleToHandle = $this->getModuleArgumentOrFail(); - $this->line($moduleToHandle->getPackageName()); - } catch (ModuleNotFound $exception) { - $this->error($exception->getMessage()); - - return self::FAILURE; - } - - return self::SUCCESS; - } - } - - beforeEach(function () { - /** @var Illuminate\Foundation\Console\Kernel $console */ - $console = $this->app[ConsoleKernelContract::class]; - $console->registerCommand(new CommandWithSingleModuleArgument($this->app[ModulesRepository::class])); - }); - - it('can accept a package name as a single "module" argument', function () { - $this->artisan('single-module-argument-command laraneat/article-category') - ->expectsOutput('laraneat/article-category') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command laraneat/article') - ->expectsOutput('laraneat/article') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command laraneat/author') - ->expectsOutput('laraneat/author') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command laraneat/empty') - ->expectsOutput('laraneat/empty') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command empty/empty') - ->expectsOutput('empty/empty') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command laraneat/location') - ->expectsOutput('laraneat/location') - ->assertSuccessful(); - }); - - it('can accept a module name as a single "module" argument', function () { - $this->artisan('single-module-argument-command Article') - ->expectsOutput('laraneat/article') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command article') - ->expectsOutput('laraneat/article') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command article-category') - ->expectsOutput('laraneat/article-category') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command ArticleCategory') - ->expectsOutput('laraneat/article-category') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command location') - ->expectsOutput('laraneat/location') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command Location') - ->expectsOutput('laraneat/location') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command navigation') - ->expectsOutput('laraneat/location') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command Navigation') - ->expectsOutput('laraneat/location') - ->assertSuccessful(); - }); - - it('displays an error message when passing an invalid single "module" argument', function () { - $this->artisan('single-module-argument-command laraneat/articlee') - ->expectsOutput("Module with 'laraneat/articlee' name or package name does not exist!") - ->assertFailed(); - - $this->artisan('single-module-argument-command laraneat') - ->expectsOutput("Module with 'laraneat' name or package name does not exist!") - ->assertFailed(); - - $this->artisan('single-module-argument-command laraneat/article/') - ->expectsOutput("Module with 'laraneat/article/' name or package name does not exist!") - ->assertFailed(); - - $this->artisan('single-module-argument-command /article') - ->expectsOutput("Module with '/article' name or package name does not exist!") - ->assertFailed(); - - $this->artisan('single-module-argument-command articlee') - ->expectsOutput("Module with 'articlee' name or package name does not exist!") - ->assertFailed(); - }); - - it('gives a module selection if the "module" argument is not passed', function () { - $this->artisan('single-module-argument-command') - ->expectsChoice( - question: 'Select one module', - answer: 'laraneat/empty', - answers: [ - 'laraneat/article-category', - 'laraneat/article', - 'laraneat/author', - 'laraneat/empty', - 'empty/empty', - 'laraneat/location', - ] - ) - ->expectsOutput('laraneat/empty') - ->assertSuccessful(); - }); - - it('gives a module selection if 2 or more modules with the same names are found', function () { - $expectedChoiceOptions = [ - 'laraneat/empty', - 'empty/empty', - ]; - - $this->artisan('single-module-argument-command Empty') - ->expectsChoice( - question: "2 modules with name 'Empty' found, please select one module from those found", - answer: 'empty/empty', - answers: $expectedChoiceOptions - ) - ->expectsOutput('empty/empty') - ->assertSuccessful(); - - $this->artisan('single-module-argument-command empty') - ->expectsChoice( - question: "2 modules with name 'empty' found, please select one module from those found", - answer: 'laraneat/empty', - answers: $expectedChoiceOptions - ) - ->expectsOutput('laraneat/empty') - ->assertSuccessful(); - }); -}); - -describe('multiple "module" argument', function () { - class CommandWithMultipleModuleArgument extends BaseCommand - { - /** - * The name and signature of the console command. - * - * @var string - */ - protected $signature = 'multiple-module-argument-command {module?* : Module name(s) or package name(s)}'; - - /** - * Execute the console command. - */ - public function handle(): int - { - try { - $modulesToHandle = $this->getModuleArgumentOrFail(); - $this->line( - collect($modulesToHandle) - ->map(fn (\Laraneat\Modules\Module $module) => $module->getPackageName()) - ->join(', ') - ); - } catch (ModuleNotFound $exception) { - $this->error($exception->getMessage()); - - return self::FAILURE; - } - - return self::SUCCESS; - } - } - - beforeEach(function () { - /** @var Illuminate\Foundation\Console\Kernel $console */ - $console = $this->app[ConsoleKernelContract::class]; - $console->registerCommand(new CommandWithMultipleModuleArgument($this->app[ModulesRepository::class])); - }); - - it('can accept a package name as a multiple "module" argument', function () { - $this->artisan('multiple-module-argument-command laraneat/article laraneat/empty empty/empty') - ->expectsOutput('laraneat/article, laraneat/empty, empty/empty') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command laraneat/article laraneat/article laraneat/empty empty/empty laraneat/empty') - ->expectsOutput('laraneat/article, laraneat/empty, empty/empty') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command laraneat/author') - ->expectsOutput('laraneat/author') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command laraneat/author laraneat/author laraneat/author') - ->expectsOutput('laraneat/author') - ->assertSuccessful(); - }); - - it('can accept "all" as a multiple "module" argument', function () { - $this->artisan('multiple-module-argument-command all') - ->expectsOutput('laraneat/article-category, laraneat/article, laraneat/author, laraneat/empty, empty/empty, laraneat/location') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command all laraneat/empty laraneat/author') - ->expectsOutput('laraneat/article-category, laraneat/article, laraneat/author, laraneat/empty, empty/empty, laraneat/location') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command laraneat/empty all laraneat/author') - ->expectsOutput('laraneat/article-category, laraneat/article, laraneat/author, laraneat/empty, empty/empty, laraneat/location') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command laraneat/author laraneat/empty all') - ->expectsOutput('laraneat/article-category, laraneat/article, laraneat/author, laraneat/empty, empty/empty, laraneat/location') - ->assertSuccessful(); - }); - - it('can accept a module name as a multiple "module" argument', function () { - $this->artisan('multiple-module-argument-command Article location Navigation ArticleCategory Location article-category') - ->expectsOutput('laraneat/article, laraneat/location, laraneat/article-category') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command ArticleCategory articleCategory article-category article_category') - ->expectsOutput('laraneat/article-category') - ->assertSuccessful(); - }); - - it('can accept a module name and package name as a multiple "module" argument', function () { - $this->artisan('multiple-module-argument-command Article location Navigation empty/empty ArticleCategory Location article-category') - ->expectsOutput('laraneat/article, laraneat/location, empty/empty, laraneat/article-category') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command ArticleCategory articleCategory laraneat/article-category article-category article_category') - ->expectsOutput('laraneat/article-category') - ->assertSuccessful(); - }); - - it('displays an error message when passing an invalid multiple "module" argument', function () { - $this->artisan('multiple-module-argument-command laraneat/empty laraneat/articlee laraneat/navigation') - ->expectsOutput("Module with 'laraneat/articlee' name or package name does not exist!") - ->assertFailed(); - - $this->artisan('multiple-module-argument-command laraneat laraneat/navigation') - ->expectsOutput("Module with 'laraneat' name or package name does not exist!") - ->assertFailed(); - - $this->artisan('multiple-module-argument-command Author laraneat/navigation') - ->expectsOutput("Module with 'laraneat/navigation' name or package name does not exist!") - ->assertFailed(); - - $this->artisan('multiple-module-argument-command article /article') - ->expectsOutput("Module with '/article' name or package name does not exist!") - ->assertFailed(); - }); - - it('gives a module selection if the multiple "module" argument is not passed', function () { - // Laravel's expectsChoice expects keys and values merged when using associative arrays - // The options are: ['all' => 'All modules', 'laraneat/article' => 'laraneat/article', ...] - // This results in: ['All modules', 'all', 'empty/empty', 'empty/empty', ...] - // In Laravel 10, Symfony adds empty string at position 0 and 'None' at position 2 for multiselect prompts - $isLaravel10 = version_compare(app()->version(), '11.0', '<'); - - $expectedAnswers = $isLaravel10 - ? [ - '', // empty string added by Symfony in Laravel 10 - 'All modules', - 'None', // 'None' option added by Symfony in Laravel 10 - 'all', - 'empty/empty', - 'empty/empty', - 'laraneat/article', - 'laraneat/article', - 'laraneat/article-category', - 'laraneat/article-category', - 'laraneat/author', - 'laraneat/author', - 'laraneat/empty', - 'laraneat/empty', - 'laraneat/location', - 'laraneat/location', - ] - : [ - 'All modules', - 'all', - 'empty/empty', - 'empty/empty', - 'laraneat/article', - 'laraneat/article', - 'laraneat/article-category', - 'laraneat/article-category', - 'laraneat/author', - 'laraneat/author', - 'laraneat/empty', - 'laraneat/empty', - 'laraneat/location', - 'laraneat/location', - ]; - - $this->artisan('multiple-module-argument-command') - ->expectsChoice( - question: 'Select one or more module', - answer: ['laraneat/article', 'empty/empty', 'laraneat/location'], - answers: $expectedAnswers - ) - ->expectsOutput('laraneat/article, empty/empty, laraneat/location') - ->assertSuccessful(); - }); - - it('gives a module selection if 2 or more modules with the same names are found', function () { - $expectedChoiceOptions = [ - 'laraneat/empty', - 'empty/empty', - ]; - - $this->artisan('multiple-module-argument-command empty laraneat/article Author Empty') - ->expectsChoice( - question: "2 modules with name 'empty' found, please select one module from those found", - answer: 'laraneat/empty', - answers: $expectedChoiceOptions - ) - ->expectsChoice( - question: "2 modules with name 'Empty' found, please select one module from those found", - answer: 'laraneat/empty', - answers: $expectedChoiceOptions - ) - ->expectsOutput('laraneat/empty, laraneat/article, laraneat/author') - ->assertSuccessful(); - - $this->artisan('multiple-module-argument-command empty laraneat/article Author Empty') - ->expectsChoice( - question: "2 modules with name 'empty' found, please select one module from those found", - answer: 'laraneat/empty', - answers: $expectedChoiceOptions - ) - ->expectsChoice( - question: "2 modules with name 'Empty' found, please select one module from those found", - answer: 'empty/empty', - answers: $expectedChoiceOptions - ) - ->expectsOutput('laraneat/empty, laraneat/article, laraneat/author, empty/empty') - ->assertSuccessful(); - }); -}); - -describe('getOptionOrAsk', function () { - class CommandWithOptionAsking extends BaseCommand - { - /** - * The name and signature of the console command. - * - * @var string - */ - protected $signature = 'command-with-option-asking {--foo=}'; - - /** - * Execute the console command. - */ - public function handle(): int - { - try { - $this->line($this->getOptionOrAsk('foo', 'Enter "foo" option')); - } catch (InvalidOptionException $exception) { - $this->line($exception->getMessage()); - - return self::FAILURE; - } - - return self::SUCCESS; - } - } - - beforeEach(function () { - /** @var Illuminate\Foundation\Console\Kernel $console */ - $console = $this->app[ConsoleKernelContract::class]; - $console->registerCommand(new CommandWithOptionAsking($this->app[ModulesRepository::class])); - }); - - it('asks for the option value if it is not specified', function () { - $this->artisan('command-with-option-asking') - ->expectsQuestion( - question: 'Enter "foo" option', - answer: 'some foo value', - ) - ->expectsOutput('some foo value') - ->assertSuccessful(); - - $this->artisan('command-with-option-asking --foo=') - ->expectsQuestion( - question: 'Enter "foo" option', - answer: 'some foo value 2', - ) - ->expectsOutput('some foo value 2') - ->assertSuccessful(); - - $this->artisan('command-with-option-asking') - ->expectsQuestion( - question: 'Enter "foo" option', - answer: '', - ) - ->expectsOutput("The 'foo' option is required") - ->assertFailed(); - }); - - it('does not ask for an option value if it specified', function () { - $this->artisan('command-with-option-asking --foo=some-foo-value') - ->expectsOutput('some-foo-value') - ->assertSuccessful(); - }); -}); - -describe('getOptionOrChoice', function () { - class CommandWithOptionChoice extends BaseCommand - { - public const CHOICES = ['first', 'second', 'third']; - - /** - * The name and signature of the console command. - * - * @var string - */ - protected $signature = 'command-with-option-choice {--foo=}'; - - /** - * Execute the console command. - */ - public function handle(): int - { - try { - $this->line($this->getOptionOrChoice('foo', 'Choice the "foo" option', self::CHOICES)); - } catch (InvalidOptionException $exception) { - $this->line($exception->getMessage()); - - return self::FAILURE; - } - - return self::SUCCESS; - } - } - - beforeEach(function () { - /** @var Illuminate\Foundation\Console\Kernel $console */ - $console = $this->app[ConsoleKernelContract::class]; - $console->registerCommand(new CommandWithOptionChoice($this->app[ModulesRepository::class])); - }); - - it('let you choose an option value if it is not specified', function () { - $this->artisan('command-with-option-choice') - ->expectsChoice( - question: 'Choice the "foo" option', - answer: 'first', - answers: CommandWithOptionChoice::CHOICES - ) - ->expectsOutput('first') - ->assertSuccessful(); - - $this->artisan('command-with-option-choice --foo=') - ->expectsChoice( - question: 'Choice the "foo" option', - answer: 'second', - answers: CommandWithOptionChoice::CHOICES - ) - ->expectsOutput('second') - ->assertSuccessful(); - }); - - it('doesnt let you choose an option value if it specified', function () { - $this->artisan('command-with-option-choice --foo=first') - ->expectsOutput('first') - ->assertSuccessful(); - }); - - it('shows an error if the passed option value is not valid', function () { - $this->artisan('command-with-option-choice --foo=some-invalid-value') - ->expectsOutput("Wrong 'foo' option value provided. Value should be one of 'first' or 'second' or 'third'.") - ->assertFailed(); - }); -}); diff --git a/tests/Commands/CacheClearCommandTest.php b/tests/Commands/CacheClearCommandTest.php deleted file mode 100644 index 779de5b..0000000 --- a/tests/Commands/CacheClearCommandTest.php +++ /dev/null @@ -1,13 +0,0 @@ -mock(ModulesRepository::class, function (MockInterface $mock) { - $mock->shouldReceive('pruneModulesManifest')->once(); - }); - $this->artisan('module:clear') - ->expectsOutputToContain('Modules manifest cache cleared!') - ->assertSuccessful(); -}); diff --git a/tests/Commands/CacheCommandTest.php b/tests/Commands/CacheCommandTest.php deleted file mode 100644 index 6206208..0000000 --- a/tests/Commands/CacheCommandTest.php +++ /dev/null @@ -1,13 +0,0 @@ -mock(ModulesRepository::class, function (MockInterface $mock) { - $mock->shouldReceive('buildModulesManifest')->once(); - }); - $this->artisan('module:cache') - ->expectsOutputToContain('Modules manifest cached!') - ->assertSuccessful(); -}); diff --git a/tests/Commands/Generators/ActionMakeCommandTest.php b/tests/Commands/Generators/ActionMakeCommandTest.php deleted file mode 100644 index 33cfc04..0000000 --- a/tests/Commands/Generators/ActionMakeCommandTest.php +++ /dev/null @@ -1,106 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "plain" action for the module', function () { - $this->artisan('module:make:action', [ - 'name' => 'PlainAuthorAction', - 'module' => 'Author', - '--stub' => 'plain', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Actions/PlainAuthorAction.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "create" action for the module', function () { - $this->artisan('module:make:action', [ - 'name' => 'CreateAuthorAction', - 'module' => 'Author', - '--stub' => 'create', - '--dto' => 'CreateAuthorDTO', - '--model' => 'Author', - '--request' => 'CreateAuthorRequest', - '--resource' => 'AuthorResource', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Actions/CreateAuthorAction.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "update" action for the module', function () { - $this->artisan('module:make:action', [ - 'name' => 'UpdateAuthorAction', - 'module' => 'Author', - '--stub' => 'update', - '--dto' => 'UpdateAuthorDTO', - '--model' => 'Author', - '--request' => 'UpdateAuthorRequest', - '--resource' => 'AuthorResource', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Actions/UpdateAuthorAction.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "delete" action for the module', function () { - $this->artisan('module:make:action', [ - 'name' => 'DeleteAuthorAction', - 'module' => 'Author', - '--stub' => 'delete', - '--model' => 'Author', - '--request' => 'DeleteAuthorRequest', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Actions/DeleteAuthorAction.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "view" action for the module', function () { - $this->artisan('module:make:action', [ - 'name' => 'ViewAuthorAction', - 'module' => 'Author', - '--stub' => 'view', - '--model' => 'Author', - '--request' => 'ViewAuthorRequest', - '--resource' => 'AuthorResource', - '--wizard' => 'AuthorQueryWizard', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Actions/ViewAuthorAction.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "list" action for the module', function () { - $this->artisan('module:make:action', [ - 'name' => 'ListAuthorsAction', - 'module' => 'Author', - '--stub' => 'list', - '--model' => 'Author', - '--request' => 'ListAuthorsRequest', - '--resource' => 'AuthorResource', - '--wizard' => 'AuthorsQueryWizard', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Actions/ListAuthorsAction.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/BaseComponentGeneratorCommandTest.php b/tests/Commands/Generators/BaseComponentGeneratorCommandTest.php deleted file mode 100644 index a29f586..0000000 --- a/tests/Commands/Generators/BaseComponentGeneratorCommandTest.php +++ /dev/null @@ -1,160 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/article-category', - __DIR__ . '/../../fixtures/stubs/modules/valid/article', - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - __DIR__ . '/../../fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/../../fixtures/stubs/modules/valid/empty', - __DIR__ . '/../../fixtures/stubs/modules/valid/navigation', - ], $this->app->basePath('/modules')); -}); - -describe('getFullClassFromOptionOrAsk()', function () { - class GetFullClassFromOptionOrAskTestCommand extends BaseComponentGeneratorCommand - { - protected $signature = 'test:command {module?} {--model=}'; - - public function handle(): int - { - $module = $this->getModuleArgumentOrFail(); - $modelClass = $this->getFullClassFromOptionOrAsk( - optionName: 'model', - question: 'Enter "model" class name', - componentType: ModuleComponentType::Model, - module: $module - ); - $this->line($modelClass); - - return self::SUCCESS; - } - - protected function getContents(): string - { - return ''; - } - } - - beforeEach(function () { - /** @var Illuminate\Foundation\Console\Kernel $console */ - $console = $this->app[ConsoleKernelContract::class]; - $console->registerCommand(new GetFullClassFromOptionOrAskTestCommand( - $this->app[ModulesRepository::class], - $this->app[Filesystem::class], - )); - }); - - - it('returns the full class of component if only the base class name is specified', function () { - $this->artisan('test:command', [ - 'module' => 'navigation', - '--model' => 'SomeGeoLocationModel', - ]) - ->expectsOutput('Modules\\GeoLocation\\Models\\SomeGeoLocationModel'); - - $this->artisan('test:command', [ - 'module' => 'navigation', - '--model' => 'Some\\GeoLocationModel', - ]) - ->expectsOutput('Modules\\GeoLocation\\Models\\Some\\GeoLocationModel'); - - $this->artisan('test:command', [ - 'module' => 'navigation', - ]) - ->expectsQuestion('Enter "model" class name', 'SomeGeoLocationModel') - ->expectsOutput('Modules\\GeoLocation\\Models\\SomeGeoLocationModel'); - - $this->artisan('test:command', [ - 'module' => 'navigation', - ]) - ->expectsQuestion('Enter "model" class name', 'Some\\GeoLocationModel') - ->expectsOutput('Modules\\GeoLocation\\Models\\Some\\GeoLocationModel'); - }); - - it('returns the specified class if it begins with a backslash', function () { - $this->artisan('test:command', [ - 'module' => 'navigation', - ]) - ->expectsQuestion('Enter "model" class name', '\\Modules\\Article\\Models\\Article') - ->expectsOutput('\\Modules\\Article\\Models\\Article'); - - $this->artisan('test:command', [ - 'module' => 'navigation', - '--model' => '\\Modules\\Article\\Models\\Article', - ]) - ->expectsOutput('\\Modules\\Article\\Models\\Article'); - - $this->artisan('test:command', [ - 'module' => 'navigation', - ]) - ->expectsQuestion('Enter "model" class name', '\\Modules\\GeoLocation\\Models\\GeoLocation') - ->expectsOutput('\\Modules\\GeoLocation\\Models\\GeoLocation'); - - $this->artisan('test:command', [ - 'module' => 'navigation', - '--model' => '\\Modules\\GeoLocation\\Models\\GeoLocation', - ]) - ->expectsOutput('\\Modules\\GeoLocation\\Models\\GeoLocation'); - }); -}); - -describe('class name validation', function () { - it('rejects invalid class names starting with a number', function () { - $this->artisan('module:make:action', [ - 'name' => '123Invalid', - 'module' => 'Author', - ]) - ->expectsOutputToContain('not a valid PHP class name') - ->assertFailed(); - }); - - it('rejects invalid class names with special characters', function () { - $this->artisan('module:make:action', [ - 'name' => 'Invalid-Class', - 'module' => 'Author', - ]) - ->expectsOutputToContain('not a valid PHP class name') - ->assertFailed(); - }); - - it('rejects invalid class names with spaces', function () { - $this->artisan('module:make:action', [ - 'name' => 'Invalid Class', - 'module' => 'Author', - ]) - ->expectsOutputToContain('not a valid PHP class name') - ->assertFailed(); - }); - - it('accepts valid class names', function () { - $this->artisan('module:make:action', [ - 'name' => 'ValidAction', - 'module' => 'Author', - '--stub' => 'plain', - ]) - ->assertSuccessful(); - - $this->artisan('module:make:action', [ - 'name' => '_ValidAction', - 'module' => 'Author', - '--stub' => 'plain', - '--force' => true, - ]) - ->assertSuccessful(); - - $this->artisan('module:make:action', [ - 'name' => 'Valid123Action', - 'module' => 'Author', - '--stub' => 'plain', - '--force' => true, - ]) - ->assertSuccessful(); - }); -}); diff --git a/tests/Commands/Generators/CommandMakeCommandTest.php b/tests/Commands/Generators/CommandMakeCommandTest.php deleted file mode 100644 index fc827b6..0000000 --- a/tests/Commands/Generators/CommandMakeCommandTest.php +++ /dev/null @@ -1,24 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates console command for the module', function () { - $this->artisan('module:make:command', [ - 'name' => 'SomeAuthorCommand', - 'module' => 'Author', - '--signature' => 'author:some-command {--foo : foo option}', - '--description' => 'Some author command description', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/CLI/Commands/SomeAuthorCommand.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/ControllerMakeCommandTest.php b/tests/Commands/Generators/ControllerMakeCommandTest.php deleted file mode 100644 index b5b8296..0000000 --- a/tests/Commands/Generators/ControllerMakeCommandTest.php +++ /dev/null @@ -1,36 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "api" controller for the module', function () { - $this->artisan('module:make:controller', [ - 'name' => 'ApiAuthorController', - 'module' => 'Author', - '--ui' => 'api', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Controllers/ApiAuthorController.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "web" controller for the module', function () { - $this->artisan('module:make:controller', [ - 'name' => 'WebAuthorController', - 'module' => 'Author', - '--ui' => 'web', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/Controllers/WebAuthorController.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/DTOMakeCommandTest.php b/tests/Commands/Generators/DTOMakeCommandTest.php deleted file mode 100644 index fe221d6..0000000 --- a/tests/Commands/Generators/DTOMakeCommandTest.php +++ /dev/null @@ -1,22 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates dto for the module', function () { - $this->artisan('module:make:dto', [ - 'name' => 'SomeAuthorDTO', - 'module' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/DTO/SomeAuthorDTO.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/EventMakeCommandTest.php b/tests/Commands/Generators/EventMakeCommandTest.php deleted file mode 100644 index d0482ff..0000000 --- a/tests/Commands/Generators/EventMakeCommandTest.php +++ /dev/null @@ -1,22 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates event for the module', function () { - $this->artisan('module:make:event', [ - 'name' => 'SomeAuthorEvent', - 'module' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Events/SomeAuthorEvent.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/ExceptionMakeCommandTest.php b/tests/Commands/Generators/ExceptionMakeCommandTest.php deleted file mode 100644 index 820b44b..0000000 --- a/tests/Commands/Generators/ExceptionMakeCommandTest.php +++ /dev/null @@ -1,50 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates exception for the module', function () { - $this->artisan('module:make:exception', [ - 'name' => 'SomeAuthorException', - 'module' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Exceptions/SomeAuthorException.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('rejects names that would escape the component directory', function (string $name) { - $filesBefore = getRelativeFilePathsInDirectory($this->app->basePath()); - - $this->artisan('module:make:exception', [ - 'name' => $name, - 'module' => 'Author', - ]) - ->expectsOutputToContain('is not a valid PHP class name') - ->assertFailed(); - - expect(getRelativeFilePathsInDirectory($this->app->basePath()))->toBe($filesBefore); -})->with([ - '../../../../SecOne', - 'Nested/../../Escape', - 'Foo/Bar-Baz', - 'Foo/./Bar', -]); - -it('accepts sub-namespaces in the name', function () { - $this->artisan('module:make:exception', [ - 'name' => 'Nested\\Deeper/SomeAuthorException', - 'module' => 'Author', - ]) - ->assertSuccessful(); - - assertFileExists($this->app->basePath('/modules/author/src/Exceptions/Nested/Deeper/SomeAuthorException.php')); -}); diff --git a/tests/Commands/Generators/FactoryMakeCommandTest.php b/tests/Commands/Generators/FactoryMakeCommandTest.php deleted file mode 100644 index 8dc4410..0000000 --- a/tests/Commands/Generators/FactoryMakeCommandTest.php +++ /dev/null @@ -1,23 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates factory for the module', function () { - $this->artisan('module:make:factory', [ - 'name' => 'SomeAuthorFactory', - 'module' => 'Author', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/database/factories/SomeAuthorFactory.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/JobMakeCommandTest.php b/tests/Commands/Generators/JobMakeCommandTest.php deleted file mode 100644 index d43b528..0000000 --- a/tests/Commands/Generators/JobMakeCommandTest.php +++ /dev/null @@ -1,36 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "plain" job for the module', function () { - $this->artisan('module:make:job', [ - 'name' => 'PlainAuthorJob', - 'module' => 'Author', - '--stub' => 'plain', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Jobs/PlainAuthorJob.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "queued" job for the module', function () { - $this->artisan('module:make:job', [ - 'name' => 'QueuedAuthorJob', - 'module' => 'Author', - '--stub' => 'queued', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Jobs/QueuedAuthorJob.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/ListenerMakeCommandTest.php b/tests/Commands/Generators/ListenerMakeCommandTest.php deleted file mode 100644 index 5b2b467..0000000 --- a/tests/Commands/Generators/ListenerMakeCommandTest.php +++ /dev/null @@ -1,38 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "plain" listener for the module', function () { - $this->artisan('module:make:listener', [ - 'name' => 'PlainAuthorListener', - 'module' => 'Author', - '--stub' => 'plain', - '--event' => 'SomeAuthorEvent', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Listeners/PlainAuthorListener.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "queued" listener for the module', function () { - $this->artisan('module:make:listener', [ - 'name' => 'QueuedAuthorListener', - 'module' => 'Author', - '--stub' => 'queued', - '--event' => 'SomeAuthorEvent', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Listeners/QueuedAuthorListener.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/MailMakeCommandTest.php b/tests/Commands/Generators/MailMakeCommandTest.php deleted file mode 100644 index 1c04001..0000000 --- a/tests/Commands/Generators/MailMakeCommandTest.php +++ /dev/null @@ -1,24 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates mail for the module', function () { - $this->artisan('module:make:mail', [ - 'name' => 'SomeAuthorMail', - 'module' => 'Author', - '--subject' => 'Some testing subject', - '--view' => 'author.mail.some-view', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Mails/SomeAuthorMail.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/MiddlewareMakeCommandTest.php b/tests/Commands/Generators/MiddlewareMakeCommandTest.php deleted file mode 100644 index 4ea6e63..0000000 --- a/tests/Commands/Generators/MiddlewareMakeCommandTest.php +++ /dev/null @@ -1,22 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates middleware for the module', function () { - $this->artisan('module:make:middleware', [ - 'name' => 'SomeAuthorMiddleware', - 'module' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Middleware/SomeAuthorMiddleware.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/MigrationMakeCommandTest.php b/tests/Commands/Generators/MigrationMakeCommandTest.php deleted file mode 100644 index 431e612..0000000 --- a/tests/Commands/Generators/MigrationMakeCommandTest.php +++ /dev/null @@ -1,231 +0,0 @@ -setModules([ - realpath(__DIR__ . '/../../fixtures/stubs/modules/valid/article'), - ], $this->app->basePath('/modules')); - - $date = Carbon::parse('2024-01-01'); - $this->travelTo($date); - $this->dateStamp = $date->format('Y_m_d_His_'); -}); - -it('generates "plain" migration for the module', function () { - $this->artisan('module:make:migration', [ - 'name' => 'some_plain_migration', - 'module' => 'Article', - '--stub' => 'plain', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}some_plain_migration.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "plain" migration for the module if the stub is not recognized by name', function () { - $this->artisan('module:make:migration', [ - 'name' => 'some_plain_migration', - 'module' => 'Article', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}some_plain_migration.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "create" migration for the module', function () { - $this->artisan('module:make:migration', [ - 'name' => 'new_articles_table', - 'module' => 'Article', - '--stub' => 'create', - '--fields' => 'title:string,excerpt:text,content:text,belongsTo:user:id:users', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}new_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "create" migration for the module without fields', function () { - $this->artisan('module:make:migration', [ - 'name' => 'new_articles_table', - 'module' => 'Article', - '--stub' => 'create', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}new_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('automatically detects and generates "create" migration for the module', function () { - $this->artisan('module:make:migration', [ - 'name' => 'create_articles_table', - 'module' => 'Article', - '--fields' => 'title:string,excerpt:text,content:text,belongsTo:user:id:users', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}create_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "add" migration for the module', function () { - $this->artisan('module:make:migration', [ - 'name' => 'modify_articles_table', - 'module' => 'Article', - '--stub' => 'add', - '--fields' => 'title:string,excerpt:text,belongsTo:user:id:users', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}modify_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "add" migration for the module without fields', function () { - $this->artisan('module:make:migration', [ - 'name' => 'modify_articles_table', - 'module' => 'Article', - '--stub' => 'add', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}modify_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('automatically detects and generates "add" migration for the module', function () { - $this->artisan('module:make:migration', [ - 'name' => 'add_title_to_articles_table', - 'module' => 'Article', - '--fields' => 'title:string', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}add_title_to_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "delete" migration for the module', function () { - $this->artisan('module:make:migration', [ - 'name' => 'modify_articles_table', - 'module' => 'Article', - '--stub' => 'delete', - '--fields' => 'title:string,excerpt:text,belongsTo:user:id:users', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}modify_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "delete" migration for the module without fields', function () { - $this->artisan('module:make:migration', [ - 'name' => 'modify_articles_table', - 'module' => 'Article', - '--stub' => 'delete', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}modify_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('automatically detects and generates "delete" migration for the module', function () { - $this->artisan('module:make:migration', [ - 'name' => 'delete_title_from_articles_table', - 'module' => 'Article', - '--fields' => 'title:string', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}delete_title_from_articles_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "pivot" migration for the module', function () { - $this->artisan('module:make:migration', [ - 'name' => 'create_article_author_table', - 'module' => 'Article', - '--stub' => 'pivot', - ]) - ->expectsQuestion('Enter the name of first table', 'articles') - ->expectsQuestion('Enter the name of second table', 'authors') - ->assertSuccessful(); - - $filePath = $this->app->basePath("/modules/article/database/migrations/{$this->dateStamp}create_article_author_table.php"); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -describe('table name validation', function () { - it('rejects invalid table names with special characters', function () { - // Use a migration name that doesn't auto-detect the table name, - // so we can enter an invalid one via the prompt - $this->artisan('module:make:migration', [ - 'name' => 'modify_something', - 'module' => 'Article', - '--stub' => 'create', - ]) - ->expectsQuestion('Enter the table name', 'invalid-table') - ->expectsOutputToContain('not valid') - ->assertFailed(); - }); - - it('rejects invalid table names starting with a number', function () { - $this->artisan('module:make:migration', [ - 'name' => 'modify_something', - 'module' => 'Article', - '--stub' => 'create', - ]) - ->expectsQuestion('Enter the table name', '123table') - ->expectsOutputToContain('not valid') - ->assertFailed(); - }); - - it('rejects invalid pivot table names', function () { - $this->artisan('module:make:migration', [ - 'name' => 'create_article_author_table', - 'module' => 'Article', - '--stub' => 'pivot', - ]) - ->expectsQuestion('Enter the name of first table', 'invalid-table') - ->expectsQuestion('Enter the name of second table', 'authors') - ->expectsOutputToContain('not valid') - ->assertFailed(); - }); - - it('accepts valid table names', function () { - $this->artisan('module:make:migration', [ - 'name' => 'create_valid_table_table', - 'module' => 'Article', - '--stub' => 'create', - ]) - ->assertSuccessful(); - - $this->artisan('module:make:migration', [ - 'name' => 'create__underscore_table_table', - 'module' => 'Article', - '--stub' => 'create', - '--force' => true, - ]) - ->assertSuccessful(); - }); -}); diff --git a/tests/Commands/Generators/ModelMakeCommandTest.php b/tests/Commands/Generators/ModelMakeCommandTest.php deleted file mode 100644 index 88d76f9..0000000 --- a/tests/Commands/Generators/ModelMakeCommandTest.php +++ /dev/null @@ -1,36 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates plain model for the module', function () { - $this->artisan('module:make:model', [ - 'name' => 'AuthorEmail', - 'module' => 'Author', - ]) - ->expectsQuestion('Enter the class name of the factory to be used in the model (optional)', '') - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Models/AuthorEmail.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates model with factory for the module', function () { - $this->artisan('module:make:model', [ - 'name' => 'AuthorEmail', - 'module' => 'Author', - '--factory' => 'AuthorEmailFactory', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Models/AuthorEmail.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/ModuleMakeCommandTest.php b/tests/Commands/Generators/ModuleMakeCommandTest.php deleted file mode 100644 index e52da3b..0000000 --- a/tests/Commands/Generators/ModuleMakeCommandTest.php +++ /dev/null @@ -1,152 +0,0 @@ -backupComposerJson(); - $this->setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/article-category', - __DIR__ . '/../../fixtures/stubs/modules/valid/article', - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - __DIR__ . '/../../fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/../../fixtures/stubs/modules/valid/empty', - __DIR__ . '/../../fixtures/stubs/modules/valid/navigation', - ]); - - $this->travelTo(Carbon::parse('2024-01-01')); -}); - -it('generates a "plain" module', function () { - $this->instance(Composer::class, $this->mockComposer(['updatePackages' => true])); - $this->artisan('module:make', [ - 'name' => 'demo/article-comment', - '--preset' => 'plain', - '--entity' => 'ArticleComment', - ]) - ->assertSuccessful(); - - assertMatchesFileSnapshot($this->app->basePath('/composer.json')); - assertsMatchesDirectorySnapshot($this->app->basePath('/modules/article-comment')); -}); - -it('generates a "base" module', function () { - $this->instance(Composer::class, $this->mockComposer(['updatePackages' => true])); - $this->artisan('module:make', [ - 'name' => 'demo/article-comment', - '--preset' => 'base', - '--entity' => 'ArticleComment', - ]) - ->expectsQuestion( - 'Enter the class name of the "Create permission action"', - 'Modules\\Authorization\\Actions\\CreatePermissionAction' - ) - ->expectsQuestion( - 'Enter the class name of the "Create permission DTO"', - 'Modules\\Authorization\\DTO\\CreatePermissionDTO' - ) - ->expectsQuestion( - 'Enter the class name of the "User model"', - 'Modules\\User\\Models\\User' - ) - ->assertSuccessful(); - - assertMatchesFileSnapshot($this->app->basePath('/composer.json')); - assertsMatchesDirectorySnapshot($this->app->basePath('/modules/article-comment')); -}); - -it('generates a "api" module', function () { - $this->instance(Composer::class, $this->mockComposer(['updatePackages' => true])); - $this->artisan('module:make', [ - 'name' => 'demo/article-comment', - '--preset' => 'api', - '--entity' => 'ArticleComment', - ]) - ->expectsQuestion( - 'Enter the class name of the "Create permission action"', - 'Modules\\Authorization\\Actions\\CreatePermissionAction' - ) - ->expectsQuestion( - 'Enter the class name of the "Create permission DTO"', - 'Modules\\Authorization\\DTO\\CreatePermissionDTO' - ) - ->expectsQuestion( - 'Enter the class name of the "User model"', - 'Modules\\User\\Models\\User' - ) - ->assertSuccessful(); - - assertMatchesFileSnapshot($this->app->basePath('/composer.json')); - assertsMatchesDirectorySnapshot($this->app->basePath('/modules/article-comment')); -}); - -it('displays an error message if the passed module name is not valid', function () { - $this->artisan('module:make', [ - 'name' => '1foo', - '--preset' => 'plain', - '--entity' => 'Foo', - ]) - ->expectsOutputToContain("The module name passed is not valid!") - ->assertFailed(); - - assertMatchesFileSnapshot($this->app->basePath('/composer.json')); -}); - -it('displays an error message when a module with the same package name already exists', function () { - $this->artisan('module:make', [ - 'name' => 'laraneat/article', - '--preset' => 'plain', - '--entity' => 'Article', - ]) - ->expectsOutputToContain("Module 'laraneat/article' already exist!") - ->assertFailed(); - - assertMatchesFileSnapshot($this->app->basePath('/composer.json')); -}); - -it('displays an error message when a module with the same folder name already exists', function () { - $this->artisan('module:make', [ - 'name' => 'demo/article', - '--preset' => 'plain', - '--entity' => 'Article', - ]) - ->expectsOutputToContain("File already exists") - ->assertFailed(); - - assertMatchesFileSnapshot($this->app->basePath('/composer.json')); -}); - -it('rejects a vendor that is not a valid composer package name', function (string $name) { - $composer = Mockery::mock(Composer::class); - $composer->shouldNotReceive('updatePackages'); - $this->instance(Composer::class, $composer); - $composerJsonBefore = file_get_contents($this->app->basePath('/composer.json')); - - $this->artisan('module:make', ['name' => $name, '--preset' => 'plain']) - ->expectsOutputToContain('is not a valid composer package name') - ->assertFailed(); - - expect($this->app->basePath('/modules/blog'))->not->toBeDirectory() - ->and(file_get_contents($this->app->basePath('/composer.json')))->toBe($composerJsonBefore); -})->with([ - 'json injection' => ['evil","scripts":{"x":"y"},"a":"/blog'], - 'option-like vendor' => ['-evil/blog'], -]); - -it('escapes the configured author when generating composer.json', function () { - $this->instance(Composer::class, $this->mockComposer(['updatePackages' => true])); - config([ - 'modules.composer.author.name' => 'Conan O"Brien \\ Co', - 'modules.composer.author.email' => 'conan@example.com', - ]); - - $this->artisan('module:make', ['name' => 'demo/blog', '--preset' => 'plain']) - ->assertSuccessful(); - - $composerJson = json_decode(file_get_contents($this->app->basePath('/modules/blog/composer.json')), true, 512, JSON_THROW_ON_ERROR); - - expect($composerJson['authors'][0])->toBe(['name' => 'Conan O"Brien \\ Co', 'email' => 'conan@example.com']) - ->and(array_keys($composerJson))->not->toContain('scripts'); -}); diff --git a/tests/Commands/Generators/NotificationMakeCommandTest.php b/tests/Commands/Generators/NotificationMakeCommandTest.php deleted file mode 100644 index b659a9c..0000000 --- a/tests/Commands/Generators/NotificationMakeCommandTest.php +++ /dev/null @@ -1,36 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "plain" notification for the module', function () { - $this->artisan('module:make:notification', [ - 'name' => 'PlainAuthorNotification', - 'module' => 'Author', - '--stub' => 'plain', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Notifications/PlainAuthorNotification.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "queued" notification for the module', function () { - $this->artisan('module:make:notification', [ - 'name' => 'QueuedAuthorNotification', - 'module' => 'Author', - '--stub' => 'queued', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Notifications/QueuedAuthorNotification.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/ObserverMakeCommandTest.php b/tests/Commands/Generators/ObserverMakeCommandTest.php deleted file mode 100644 index 77c64d8..0000000 --- a/tests/Commands/Generators/ObserverMakeCommandTest.php +++ /dev/null @@ -1,23 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates observer for the module', function () { - $this->artisan('module:make:observer', [ - 'name' => 'SomeAuthorObserver', - 'module' => 'Author', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Observers/SomeAuthorObserver.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/PolicyMakeCommandTest.php b/tests/Commands/Generators/PolicyMakeCommandTest.php deleted file mode 100644 index bf926ae..0000000 --- a/tests/Commands/Generators/PolicyMakeCommandTest.php +++ /dev/null @@ -1,40 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates plain policy for the module', function () { - $this->artisan('module:make:policy', [ - 'name' => 'AuthorPolicy', - 'module' => 'Author', - ]) - ->expectsQuestion('Enter the class name of the model to be used in the policy (optional)', '') - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Policies/AuthorPolicy.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates full policy for the module', function () { - $this->artisan('module:make:policy', [ - 'name' => 'AuthorPolicy', - 'module' => 'Author', - '--model' => 'Author', - ]) - ->expectsQuestion( - 'Enter the class name of the "User model"', - 'Modules\\User\\Models\\User' - ) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Policies/AuthorPolicy.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/ProviderMakeCommandTest.php b/tests/Commands/Generators/ProviderMakeCommandTest.php deleted file mode 100644 index 99a8c74..0000000 --- a/tests/Commands/Generators/ProviderMakeCommandTest.php +++ /dev/null @@ -1,77 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "plain" provider for the module', function () { - $this->artisan('module:make:provider', [ - 'name' => 'TestServiceProvider', - 'module' => 'Author', - '--stub' => 'plain', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Providers/TestServiceProvider.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "module" provider for the module', function () { - $this->artisan('module:make:provider', [ - 'name' => 'CustomModuleServiceProvider', - 'module' => 'Author', - '--stub' => 'module', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Providers/CustomModuleServiceProvider.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "event" provider for the module', function () { - $this->artisan('module:make:provider', [ - 'name' => 'EventServiceProvider', - 'module' => 'Author', - '--stub' => 'event', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Providers/EventServiceProvider.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "route" provider for the module', function () { - $this->artisan('module:make:provider', [ - 'name' => 'CustomRouteServiceProvider', - 'module' => 'Author', - '--stub' => 'route', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Providers/CustomRouteServiceProvider.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('registers provider in module composer.json', function () { - $this->artisan('module:make:provider', [ - 'name' => 'CustomServiceProvider', - 'module' => 'Author', - '--stub' => 'plain', - ]) - ->assertSuccessful(); - - $composerJsonPath = $this->app->basePath('/modules/author/composer.json'); - $composerJson = json_decode(file_get_contents($composerJsonPath), true); - - expect($composerJson['extra']['laravel']['providers']) - ->toContain('Modules\\Author\\Providers\\CustomServiceProvider'); -}); diff --git a/tests/Commands/Generators/QueryWizardMakeCommandTest.php b/tests/Commands/Generators/QueryWizardMakeCommandTest.php deleted file mode 100644 index f2852f0..0000000 --- a/tests/Commands/Generators/QueryWizardMakeCommandTest.php +++ /dev/null @@ -1,36 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "eloquent" query-wizard for the module', function () { - $this->artisan('module:make:query-wizard', [ - 'name' => 'AuthorsQueryWizard', - 'module' => 'Author', - '--stub' => 'eloquent', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/QueryWizards/AuthorsQueryWizard.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "model" query-wizard for the module', function () { - $this->artisan('module:make:query-wizard', [ - 'name' => 'AuthorQueryWizard', - 'module' => 'Author', - '--stub' => 'model', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/QueryWizards/AuthorQueryWizard.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/RequestMakeCommandTest.php b/tests/Commands/Generators/RequestMakeCommandTest.php deleted file mode 100644 index aed4fdf..0000000 --- a/tests/Commands/Generators/RequestMakeCommandTest.php +++ /dev/null @@ -1,162 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "plain" web request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'PlainAuthorRequest', - 'module' => 'Author', - '--stub' => 'plain', - '--ui' => 'web', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/Requests/PlainAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "create" web request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'CreateAuthorRequest', - 'module' => 'Author', - '--stub' => 'create', - '--ui' => 'web', - '--dto' => 'CreateAuthorDTO', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/Requests/CreateAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "update" web request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'UpdateAuthorRequest', - 'module' => 'Author', - '--stub' => 'update', - '--ui' => 'web', - '--dto' => 'UpdateAuthorDTO', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/Requests/UpdateAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "delete" web request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'DeleteAuthorRequest', - 'module' => 'Author', - '--stub' => 'delete', - '--ui' => 'web', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/Requests/DeleteAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "plain" api request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'PlainAuthorRequest', - 'module' => 'Author', - '--stub' => 'plain', - '--ui' => 'api', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Requests/PlainAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "create" api request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'CreateAuthorRequest', - 'module' => 'Author', - '--stub' => 'create', - '--ui' => 'api', - '--dto' => 'CreateAuthorDTO', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Requests/CreateAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "update" api request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'UpdateAuthorRequest', - 'module' => 'Author', - '--stub' => 'update', - '--ui' => 'api', - '--dto' => 'UpdateAuthorDTO', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Requests/UpdateAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "delete" api request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'DeleteAuthorRequest', - 'module' => 'Author', - '--stub' => 'delete', - '--ui' => 'api', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Requests/DeleteAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "view" api request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'ViewAuthorRequest', - 'module' => 'Author', - '--stub' => 'view', - '--ui' => 'api', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Requests/ViewAuthorRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "list" api request for the module', function () { - $this->artisan('module:make:request', [ - 'name' => 'ListAuthorsRequest', - 'module' => 'Author', - '--stub' => 'list', - '--ui' => 'api', - '--model' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Requests/ListAuthorsRequest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/ResourceMakeCommandTest.php b/tests/Commands/Generators/ResourceMakeCommandTest.php deleted file mode 100644 index 8894423..0000000 --- a/tests/Commands/Generators/ResourceMakeCommandTest.php +++ /dev/null @@ -1,36 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "single" resource for the module', function () { - $this->artisan('module:make:resource', [ - 'name' => 'AuthorResource', - 'module' => 'Author', - '--stub' => 'single', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Resources/AuthorResource.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "collection" resource for the module', function () { - $this->artisan('module:make:resource', [ - 'name' => 'AuthorResourceCollection', - 'module' => 'Author', - '--stub' => 'collection', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/Resources/AuthorResourceCollection.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/RouteMakeCommandTest.php b/tests/Commands/Generators/RouteMakeCommandTest.php deleted file mode 100644 index 13d8c46..0000000 --- a/tests/Commands/Generators/RouteMakeCommandTest.php +++ /dev/null @@ -1,214 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "get" web route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'list_authors', - 'module' => 'Author', - '--ui' => 'web', - '--action' => 'ListAuthorsAction', - '--method' => 'get', - '--url' => 'authors', - '--name' => 'web.authors.list', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/routes/list_authors.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "post" web route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'create_author', - 'module' => 'Author', - '--ui' => 'web', - '--action' => 'CreateAuthorAction', - '--method' => 'post', - '--url' => 'authors', - '--name' => 'web.authors.create', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/routes/create_author.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "put" web route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'update_author', - 'module' => 'Author', - '--ui' => 'web', - '--action' => 'UpdateAuthorAction', - '--method' => 'put', - '--url' => 'authors/{author}', - '--name' => 'web.authors.update', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/routes/update_author.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "patch" web route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'update_author', - 'module' => 'Author', - '--ui' => 'web', - '--action' => 'UpdateAuthorAction', - '--method' => 'patch', - '--url' => 'authors/{author}', - '--name' => 'web.authors.update', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/routes/update_author.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "delete" web route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'delete_author', - 'module' => 'Author', - '--ui' => 'web', - '--action' => 'DeleteAuthorAction', - '--method' => 'delete', - '--url' => 'authors/{author}', - '--name' => 'web.authors.delete', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/routes/delete_author.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "options" web route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'options_authors', - 'module' => 'Author', - '--ui' => 'web', - '--action' => 'ListAuthorsAction', - '--method' => 'options', - '--url' => 'authors', - '--name' => 'web.authors.options', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/WEB/routes/options_authors.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "get" api route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'list_authors', - 'module' => 'Author', - '--ui' => 'api', - '--action' => 'ListAuthorsAction', - '--method' => 'get', - '--url' => 'authors', - '--name' => 'web.authors.list', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/routes/list_authors.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "post" api route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'create_author', - 'module' => 'Author', - '--ui' => 'api', - '--action' => 'CreateAuthorAction', - '--method' => 'post', - '--url' => 'authors', - '--name' => 'api.authors.create', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/routes/create_author.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "put" api route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'update_author', - 'module' => 'Author', - '--ui' => 'api', - '--action' => 'UpdateAuthorAction', - '--method' => 'put', - '--url' => 'authors/{author}', - '--name' => 'api.authors.update', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/routes/update_author.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "patch" api route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'update_author', - 'module' => 'Author', - '--ui' => 'api', - '--action' => 'UpdateAuthorAction', - '--method' => 'patch', - '--url' => 'authors/{author}', - '--name' => 'api.authors.update', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/routes/update_author.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "delete" api route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'delete_author', - 'module' => 'Author', - '--ui' => 'api', - '--action' => 'DeleteAuthorAction', - '--method' => 'delete', - '--url' => 'authors/{author}', - '--name' => 'api.authors.delete', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/routes/delete_author.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "options" api route for the module', function () { - $this->artisan('module:make:route', [ - 'name' => 'options_authors', - 'module' => 'Author', - '--ui' => 'api', - '--action' => 'ListAuthorsAction', - '--method' => 'options', - '--url' => 'authors', - '--name' => 'api.authors.options', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/UI/API/routes/options_authors.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/RuleMakeCommandTest.php b/tests/Commands/Generators/RuleMakeCommandTest.php deleted file mode 100644 index 7ff2d59..0000000 --- a/tests/Commands/Generators/RuleMakeCommandTest.php +++ /dev/null @@ -1,22 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates rule for the module', function () { - $this->artisan('module:make:rule', [ - 'name' => 'SomeAuthorRule', - 'module' => 'Author', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/src/Rules/SomeAuthorRule.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/SeederMakeCommandTest.php b/tests/Commands/Generators/SeederMakeCommandTest.php deleted file mode 100644 index 4873e25..0000000 --- a/tests/Commands/Generators/SeederMakeCommandTest.php +++ /dev/null @@ -1,45 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "plain" seeder for the module', function () { - $this->artisan('module:make:seeder', [ - 'name' => 'AuthorsSeeder', - 'module' => 'Author', - '--stub' => 'plain', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/database/seeders/AuthorsSeeder.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates "permissions" seeder for the module', function () { - $this->artisan('module:make:seeder', [ - 'name' => 'AuthorPermissionsSeeder', - 'module' => 'Author', - '--stub' => 'permissions', - '--model' => 'Author', - ]) - ->expectsQuestion( - 'Enter the class name of the "Create permission action"', - 'Modules\\Authorization\\Actions\\CreatePermissionAction' - ) - ->expectsQuestion( - 'Enter the class name of the "Create permission DTO"', - 'Modules\\Authorization\\DTO\\CreatePermissionDTO' - ) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/database/seeders/AuthorPermissionsSeeder.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/Generators/TestMakeCommandTest.php b/tests/Commands/Generators/TestMakeCommandTest.php deleted file mode 100644 index e56875e..0000000 --- a/tests/Commands/Generators/TestMakeCommandTest.php +++ /dev/null @@ -1,209 +0,0 @@ -setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('generates "unit" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'ExampleTest', - 'module' => 'Author', - '--type' => 'unit', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/Unit/ExampleTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates plain "feature" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'ExampleTest', - 'module' => 'Author', - '--type' => 'feature', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/Feature/ExampleTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates plain "cli" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'ExampleTest', - 'module' => 'Author', - '--type' => 'cli', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/CLI/ExampleTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates plain "web" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'ExampleTest', - 'module' => 'Author', - '--type' => 'web', - '--stub' => 'plain', - '--model' => 'Author', - '--route' => 'web.authors.example', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/WEB/ExampleTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates create "web" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'CreateAuthorTest', - 'module' => 'Author', - '--type' => 'web', - '--stub' => 'create', - '--model' => 'Author', - '--route' => 'web.authors.create', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/WEB/CreateAuthorTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates update "web" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'UpdateAuthorTest', - 'module' => 'Author', - '--type' => 'web', - '--stub' => 'update', - '--model' => 'Author', - '--route' => 'web.authors.update', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/WEB/UpdateAuthorTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates delete "web" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'DeleteAuthorTest', - 'module' => 'Author', - '--type' => 'web', - '--stub' => 'delete', - '--model' => 'Author', - '--route' => 'web.authors.delete', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/WEB/DeleteAuthorTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates plain "api" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'ExampleTest', - 'module' => 'Author', - '--type' => 'api', - '--stub' => 'plain', - '--model' => 'Author', - '--route' => 'api.authors.example', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/API/ExampleTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates create "api" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'CreateAuthorTest', - 'module' => 'Author', - '--type' => 'api', - '--stub' => 'create', - '--model' => 'Author', - '--route' => 'api.authors.create', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/API/CreateAuthorTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates update "api" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'UpdateAuthorTest', - 'module' => 'Author', - '--type' => 'api', - '--stub' => 'update', - '--model' => 'Author', - '--route' => 'api.authors.update', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/API/UpdateAuthorTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates delete "api" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'DeleteAuthorTest', - 'module' => 'Author', - '--type' => 'api', - '--stub' => 'delete', - '--model' => 'Author', - '--route' => 'api.authors.delete', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/API/DeleteAuthorTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates list "api" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'ListAuthorsTest', - 'module' => 'Author', - '--type' => 'api', - '--stub' => 'list', - '--model' => 'Author', - '--route' => 'api.authors.list', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/API/ListAuthorsTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); - -it('generates view "api" test for the module', function () { - $this->artisan('module:make:test', [ - 'name' => 'ViewAuthorTest', - 'module' => 'Author', - '--type' => 'api', - '--stub' => 'view', - '--model' => 'Author', - '--route' => 'api.authors.view', - ]) - ->assertSuccessful(); - - $filePath = $this->app->basePath('/modules/author/tests/UI/API/ViewAuthorTest.php'); - assertFileExists($filePath); - assertMatchesFileSnapshot($filePath); -}); diff --git a/tests/Commands/ListCommandTest.php b/tests/Commands/ListCommandTest.php deleted file mode 100644 index 4a026e8..0000000 --- a/tests/Commands/ListCommandTest.php +++ /dev/null @@ -1,26 +0,0 @@ -setModules([ - __DIR__ . '/../fixtures/stubs/modules/valid/article-category', - __DIR__ . '/../fixtures/stubs/modules/valid/article', - __DIR__ . '/../fixtures/stubs/modules/valid/author', - __DIR__ . '/../fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/../fixtures/stubs/modules/valid/empty', - __DIR__ . '/../fixtures/stubs/modules/valid/navigation', - ], $this->app->basePath('/modules')); -}); - -it('outputs a table of modules', function () { - $this->artisan('module:list') - ->expectsTable(['Package Name', 'Namespace', 'Path'], [ - ['laraneat/article-category', 'Modules\\ArticleCategory', $this->app->basePath('/modules/article-category')], - ['laraneat/article', 'Modules\\Article', $this->app->basePath('/modules/article')], - ['laraneat/author', 'Modules\\Author', $this->app->basePath('/modules/author')], - ['laraneat/empty', 'Modules\\Empty', $this->app->basePath('/modules/empty-module')], - ['empty/empty', 'Empty\\Empty', $this->app->basePath('/modules/empty')], - ['laraneat/location', 'Modules\\GeoLocation', $this->app->basePath('/modules/navigation')], - - ]) - ->assertSuccessful(); -}); diff --git a/tests/Commands/MigrateCommandTest.php b/tests/Commands/MigrateCommandTest.php deleted file mode 100644 index a477d21..0000000 --- a/tests/Commands/MigrateCommandTest.php +++ /dev/null @@ -1,37 +0,0 @@ -setModules([ - __DIR__ . '/../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('runs migrations for the specified module', function () { - $this->artisan('module:migrate', [ - 'module' => 'Author', - '--pretend' => true, - ]) - ->assertSuccessful(); -}); - -it('requires confirmation in production', function () { - $this->app['env'] = 'production'; - - $this->artisan('module:migrate', [ - 'module' => 'Author', - '--pretend' => true, - ]) - ->expectsConfirmation('Are you sure you want to run this command?', 'no') - ->assertFailed(); -}); - -it('can be forced in production', function () { - $this->app['env'] = 'production'; - - $this->artisan('module:migrate', [ - 'module' => 'Author', - '--pretend' => true, - '--force' => true, - ]) - ->assertSuccessful(); -}); diff --git a/tests/Commands/MigrateRefreshCommandTest.php b/tests/Commands/MigrateRefreshCommandTest.php deleted file mode 100644 index cfb0555..0000000 --- a/tests/Commands/MigrateRefreshCommandTest.php +++ /dev/null @@ -1,37 +0,0 @@ -setModules([ - __DIR__ . '/../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('refreshes migrations for the specified module', function () { - $this->artisan('module:migrate:refresh', [ - 'module' => 'Author', - '--pretend' => true, - ]) - ->assertSuccessful(); -}); - -it('requires confirmation in production', function () { - $this->app['env'] = 'production'; - - $this->artisan('module:migrate:refresh', [ - 'module' => 'Author', - '--pretend' => true, - ]) - ->expectsConfirmation('Are you sure you want to run this command?', 'no') - ->assertFailed(); -}); - -it('can be forced in production', function () { - $this->app['env'] = 'production'; - - $this->artisan('module:migrate:refresh', [ - 'module' => 'Author', - '--pretend' => true, - '--force' => true, - ]) - ->assertSuccessful(); -}); diff --git a/tests/Commands/MigrateResetCommandTest.php b/tests/Commands/MigrateResetCommandTest.php deleted file mode 100644 index 9e8812f..0000000 --- a/tests/Commands/MigrateResetCommandTest.php +++ /dev/null @@ -1,37 +0,0 @@ -setModules([ - __DIR__ . '/../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('resets migrations for the specified module', function () { - $this->artisan('module:migrate:reset', [ - 'module' => 'Author', - '--pretend' => true, - ]) - ->assertSuccessful(); -}); - -it('requires confirmation in production', function () { - $this->app['env'] = 'production'; - - $this->artisan('module:migrate:reset', [ - 'module' => 'Author', - '--pretend' => true, - ]) - ->expectsConfirmation('Are you sure you want to run this command?', 'no') - ->assertFailed(); -}); - -it('can be forced in production', function () { - $this->app['env'] = 'production'; - - $this->artisan('module:migrate:reset', [ - 'module' => 'Author', - '--pretend' => true, - '--force' => true, - ]) - ->assertSuccessful(); -}); diff --git a/tests/Commands/MigrateRollbackCommandTest.php b/tests/Commands/MigrateRollbackCommandTest.php deleted file mode 100644 index 9c07dba..0000000 --- a/tests/Commands/MigrateRollbackCommandTest.php +++ /dev/null @@ -1,37 +0,0 @@ -setModules([ - __DIR__ . '/../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('rolls back migrations for the specified module', function () { - $this->artisan('module:migrate:rollback', [ - 'module' => 'Author', - '--pretend' => true, - ]) - ->assertSuccessful(); -}); - -it('requires confirmation in production', function () { - $this->app['env'] = 'production'; - - $this->artisan('module:migrate:rollback', [ - 'module' => 'Author', - '--pretend' => true, - ]) - ->expectsConfirmation('Are you sure you want to run this command?', 'no') - ->assertFailed(); -}); - -it('can be forced in production', function () { - $this->app['env'] = 'production'; - - $this->artisan('module:migrate:rollback', [ - 'module' => 'Author', - '--pretend' => true, - '--force' => true, - ]) - ->assertSuccessful(); -}); diff --git a/tests/Commands/MigrateStatusCommandTest.php b/tests/Commands/MigrateStatusCommandTest.php deleted file mode 100644 index 457911e..0000000 --- a/tests/Commands/MigrateStatusCommandTest.php +++ /dev/null @@ -1,24 +0,0 @@ -setModules([ - __DIR__ . '/../fixtures/stubs/modules/valid/author', - ], $this->app->basePath('/modules')); -}); - -it('shows migration status for the specified module', function () { - $this->artisan('module:migrate:status', [ - 'module' => 'Author', - ]) - ->assertSuccessful(); -}); - -it('does not require confirmation in production', function () { - $this->app['env'] = 'production'; - - // migrate:status should not require confirmation as it's read-only - $this->artisan('module:migrate:status', [ - 'module' => 'Author', - ]) - ->assertSuccessful(); -}); diff --git a/tests/Commands/ModuleDeleteCommandTest.php b/tests/Commands/ModuleDeleteCommandTest.php deleted file mode 100644 index 5341916..0000000 --- a/tests/Commands/ModuleDeleteCommandTest.php +++ /dev/null @@ -1,106 +0,0 @@ -instance(Composer::class, $this->mockComposer(['removePackages' => true])); - - // Rebind ModulesRepository with the mocked Composer - $this->app->singleton(ModulesRepository::class, function ($app) { - return new ModulesRepository( - filesystem: $app['files'], - composer: $app[Composer::class], - modulesPath: $app['config']->get('modules.path'), - basePath: $app->basePath(), - modulesManifestPath: $app['config']->get('modules.cache.enabled') - ? $app->bootstrapPath('cache/laraneat-modules.php') - : null - ); - }); - - $this->setModules([ - __DIR__ . '/../fixtures/stubs/modules/valid/article-category', - __DIR__ . '/../fixtures/stubs/modules/valid/article', - __DIR__ . '/../fixtures/stubs/modules/valid/author', - __DIR__ . '/../fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/../fixtures/stubs/modules/valid/empty', - __DIR__ . '/../fixtures/stubs/modules/valid/navigation', - ], $this->app->basePath('/modules')); - - /** @var ModulesRepository $modulesRepository */ - $modulesRepository = $this->app[ModulesRepository::class]; - $this->modulesRepository = $modulesRepository; -}); - -it('deletes a module', function () { - expect($this->modulesRepository->has('laraneat/article'))->toBe(true); - - $this->artisan('module:delete article') - ->assertSuccessful(); - - expect($this->modulesRepository->has('laraneat/article'))->toBe(false); -}); - -it('removes the composer package before deleting the module files', function () { - $modulePath = $this->modulesRepository->findOrFail('laraneat/article')->getPath(); - - $composer = Mockery::mock(Composer::class); - $composer->shouldReceive('removePackages') - ->once() - ->withArgs(function (array $packages) use ($modulePath) { - // the files must still exist while composer runs - expect(is_dir($modulePath))->toBeTrue(); - - return $packages === ['laraneat/article']; - }) - ->andReturn(true); - $this->app->instance(Composer::class, $composer); - $this->app->forgetInstance(ModulesRepository::class); - - $this->artisan('module:delete article') - ->assertSuccessful(); - - expect(is_dir($modulePath))->toBeFalse(); -}); - -it('keeps the module files and fails when composer cannot remove the package', function () { - $modulePath = $this->modulesRepository->findOrFail('laraneat/article')->getPath(); - - $composer = Mockery::mock(Composer::class); - $composer->shouldReceive('removePackages')->once()->andReturn(false); - $this->app->instance(Composer::class, $composer); - $this->app->forgetInstance(ModulesRepository::class); - - $this->artisan('module:delete article') - ->expectsOutputToContain('Failed to remove package with composer.') - ->assertFailed(); - - expect(is_dir($modulePath))->toBeTrue(); -}); - -it('refuses to delete a symlinked module and leaves its target intact', function () { - $target = sys_get_temp_dir() . '/laraneat-symlink-target-' . uniqid(); - $this->filesystem->copyDirectory(__DIR__ . '/../fixtures/stubs/modules/valid/navigation', $target); - $this->filesystem->deleteDirectory($this->app->basePath('/modules/navigation')); - symlink($target, $this->app->basePath('/modules/navigation')); - $this->app[ModulesRepository::class]->pruneModulesManifest(); - - $composer = Mockery::mock(Composer::class); - $composer->shouldNotReceive('removePackages'); - $this->app->instance(Composer::class, $composer); - $this->app->forgetInstance(ModulesRepository::class); - - try { - $this->artisan('module:delete navigation') - ->expectsOutputToContain('is a symbolic link') - ->assertFailed(); - - expect(is_link($this->app->basePath('/modules/navigation')))->toBeTrue() - ->and(is_file($target . '/composer.json'))->toBeTrue(); - } finally { - @unlink($this->app->basePath('/modules/navigation')); - $this->filesystem->deleteDirectory($target); - } -}); diff --git a/tests/Commands/StubPublishCommandTest.php b/tests/Commands/StubPublishCommandTest.php deleted file mode 100644 index 482dd3f..0000000 --- a/tests/Commands/StubPublishCommandTest.php +++ /dev/null @@ -1,18 +0,0 @@ -customStubsPath = $this->app['config']->get('modules.custom_stubs'); -}); - -afterEach(function () { - $this->filesystem->deleteDirectory($this->customStubsPath); -}); - -it('publish all laraneat/modules stubs', function () { - $this->artisan('module:stub:publish') - ->assertSuccessful(); - - assertMatchesSnapshot(getRelativeFilePathsInDirectory($this->customStubsPath)); -}); diff --git a/tests/Commands/SyncCommandTest.php b/tests/Commands/SyncCommandTest.php deleted file mode 100644 index bc9c5de..0000000 --- a/tests/Commands/SyncCommandTest.php +++ /dev/null @@ -1,62 +0,0 @@ -mock(ModulesRepository::class, function (MockInterface $mock) { - $mock->shouldReceive('pruneModulesManifest')->once(); - $mock->shouldReceive('syncWithComposer')->once(); - }); - - $this->artisan('module:sync') - ->expectsOutputToContain('Modules completed successfully!') - ->assertSuccessful(); -}); - -it('handles ModuleHasNoNamespace exception', function () { - $this->mock(ModulesRepository::class, function (MockInterface $mock) { - $mock->shouldReceive('pruneModulesManifest')->once(); - $mock->shouldReceive('syncWithComposer')->once()->andThrow( - ModuleHasNoNamespace::make('test/module') - ); - }); - - $this->artisan('module:sync') - ->expectsOutputToContain('No namespace specified for module') - ->assertFailed(); -}); - -it('handles ModuleHasNonUniquePackageName exception', function () { - $this->mock(ModulesRepository::class, function (MockInterface $mock) { - $mock->shouldReceive('pruneModulesManifest')->once(); - $mock->shouldReceive('syncWithComposer')->once()->andThrow( - ModuleHasNonUniquePackageName::make('test/module', ['/path1', '/path2']) - ); - }); - - $this->artisan('module:sync') - ->expectsOutputToContain('test/module') - ->assertFailed(); -}); - -it('handles ComposerException and shows manual update hint', function () { - $this->mock(ModulesRepository::class, function (MockInterface $mock) { - $mock->shouldReceive('pruneModulesManifest')->once(); - $mock->shouldReceive('syncWithComposer')->once()->andThrow( - ComposerException::make('Failed to update package with composer.') - ); - $mock->shouldReceive('getModules')->once()->andReturn([ - 'test/module-a' => (object) [], - 'test/module-b' => (object) [], - ]); - }); - - $this->artisan('module:sync') - ->expectsOutputToContain('Failed to update package with composer') - ->expectsOutputToContain('composer update test/module-a test/module-b') - ->assertFailed(); -}); diff --git a/tests/ModuleTest.php b/tests/ModuleTest.php deleted file mode 100644 index 1b49a92..0000000 --- a/tests/ModuleTest.php +++ /dev/null @@ -1,272 +0,0 @@ -createModule(['packageName' => 'some-vendor/testing-module'])->getPackageName())->toBe('some-vendor/testing-module'); - expect($this->createModule(['packageName' => 'testing-module'])->getPackageName())->toBe('testing-module'); - expect($this->createModule(['packageName' => ' some-vendor/module '])->getPackageName())->toBe('some-vendor/module'); -}); - -it('can return the name', function () { - expect($this->createModule(['name' => 'TestingModule'])->getName())->toBe('TestingModule'); - expect($this->createModule(['name' => ' SomeModule '])->getName())->toBe('SomeModule'); - expect($this->createModule(['name' => ' some-module '])->getName())->toBe('some-module'); - - expect($this->createModule(['packageName' => 'some-vendor/testing-module'])->getName())->toBe('testing-module'); - expect($this->createModule(['packageName' => ' some-vendor/module '])->getName())->toBe('module'); - expect($this->createModule(['packageName' => 'testing-module'])->getName())->toBe('testing-module'); -}); - -it('can return the studly name', function () { - expect($this->createModule(['name' => 'TestingModule'])->getStudlyName())->toBe('TestingModule'); - expect($this->createModule(['name' => ' SomeModule '])->getStudlyName())->toBe('SomeModule'); - expect($this->createModule(['name' => ' some-module '])->getStudlyName())->toBe('SomeModule'); - - expect($this->createModule(['packageName' => 'some-vendor/testing-module'])->getStudlyName())->toBe('TestingModule'); - expect($this->createModule(['packageName' => ' some-vendor/module '])->getStudlyName())->toBe('Module'); - expect($this->createModule(['packageName' => 'testing-module'])->getStudlyName())->toBe('TestingModule'); -}); - -it('can return the kebab name', function () { - expect($this->createModule(['name' => 'TestingModule'])->getKebabName())->toBe('testing-module'); - expect($this->createModule(['name' => ' SomeModule '])->getKebabName())->toBe('some-module'); - expect($this->createModule(['name' => ' some-module '])->getKebabName())->toBe('some-module'); - - expect($this->createModule(['packageName' => 'some-vendor/testing-module'])->getKebabName())->toBe('testing-module'); - expect($this->createModule(['packageName' => ' some-vendor/module '])->getKebabName())->toBe('module'); - expect($this->createModule(['packageName' => 'testing-module'])->getKebabName())->toBe('testing-module'); -}); - -it('can return the snake name', function () { - expect($this->createModule(['name' => 'TestingModule'])->getSnakeName())->toBe('testing_module'); - expect($this->createModule(['name' => ' SomeModule '])->getSnakeName())->toBe('some_module'); - expect($this->createModule(['name' => ' some-module '])->getSnakeName())->toBe('some_module'); - - expect($this->createModule(['packageName' => 'some-vendor/testing-module'])->getSnakeName())->toBe('testing_module'); - expect($this->createModule(['packageName' => ' some-vendor/module '])->getSnakeName())->toBe('module'); - expect($this->createModule(['packageName' => 'testing-module'])->getSnakeName())->toBe('testing_module'); -}); - -it('can return the path', function () { - expect($this->createModule([ - 'path' => $this->app->basePath('/modules/SomeTestingModule'), - ])->getPath())->toBe($this->app->basePath('/modules/SomeTestingModule')); -}); - -it('can return the namespace', function () { - expect($this->createModule(['namespace' => 'Some\\TestingModule\\'])->getNamespace()) - ->toBe('Some\\TestingModule'); - expect($this->createModule(['namespace' => 'Some\\TestingModule\\\\\\'])->getNamespace()) - ->toBe('Some\\TestingModule'); - expect($this->createModule(['namespace' => '\\\\Some\\TestingModule'])->getNamespace()) - ->toBe('Some\\TestingModule'); - expect($this->createModule(['namespace' => '\\\\Some\\TestingModule\\\\\\'])->getNamespace()) - ->toBe('Some\\TestingModule'); -}); - -it('can return providers', function () { - expect($this->createModule([ - 'providers' => [ - 'SomeVendor\\TestingModule\\Providers\\TestingModuleServiceProvider', - 'SomeVendor\\TestingModule\\Providers\\RouteServiceProvider', - ], - ])->getProviders())->toBe([ - 'SomeVendor\\TestingModule\\Providers\\TestingModuleServiceProvider', - 'SomeVendor\\TestingModule\\Providers\\RouteServiceProvider', - ]); -}); - -it('can return aliases', function () { - expect($this->createModule([ - 'aliases' => [ - 'testing-module' => 'SomeVendor\\TestingModule\\Facades\\TestingModule', - 'some' => 'SomeVendor\\TestingModule\\Facades\\Some', - ], - ])->getAliases())->toBe([ - 'testing-module' => 'SomeVendor\\TestingModule\\Facades\\TestingModule', - 'some' => 'SomeVendor\\TestingModule\\Facades\\Some', - ]); -}); - -it('can make sub path', function () { - expect($this->createModule(['path' => $this->app->basePath('/modules/SomeTestingModule')]) - ->subPath('resources/views/index.blade.php')) - ->toBe($this->app->basePath('/modules/SomeTestingModule/resources/views/index.blade.php')); - - expect($this->createModule(['path' => $this->app->basePath('/modules/SomeTestingModule')]) - ->subPath('///resources/views/index.blade.php')) - ->toBe($this->app->basePath('/modules/SomeTestingModule/resources/views/index.blade.php')); -}); - -it('can make sub namespace', function () { - expect($this->createModule(['namespace' => 'SomeVendor\\SomeTestingModule']) - ->subNamespace('Models\\SomeModel')) - ->toBe('SomeVendor\\SomeTestingModule\\Models\\SomeModel'); - - expect($this->createModule(['namespace' => 'SomeVendor\\SomeTestingModule']) - ->subNamespace('\\\\Models\\SomeModel\\\\')) - ->toBe('SomeVendor\\SomeTestingModule\\Models\\SomeModel'); -}); - -it('can return module as array', function () { - expect($this->createModule([ - 'path' => $this->app->basePath('modules/TestingModule'), - 'packageName' => 'some-vendor/testing-module', - 'name' => 'TestingModule', - 'namespace' => '\\\\\\SomeVendor\\TestingModule\\\\', - 'providers' => [ - 'SomeVendor\\TestingModule\\Providers\\TestingModuleServiceProvider', - 'SomeVendor\\TestingModule\\Providers\\RouteServiceProvider', - ], - 'aliases' => [ - 'testing-module' => 'SomeVendor\\TestingModule\\Facades\\TestingModule', - 'some' => 'SomeVendor\\TestingModule\\Facades\\Some', - ], - ])->toArray())->toBe([ - 'path' => $this->app->basePath('modules/TestingModule'), - 'packageName' => 'some-vendor/testing-module', - 'name' => 'TestingModule', - 'namespace' => 'SomeVendor\\TestingModule', - 'providers' => [ - 'SomeVendor\\TestingModule\\Providers\\TestingModuleServiceProvider', - 'SomeVendor\\TestingModule\\Providers\\RouteServiceProvider', - ], - 'aliases' => [ - 'testing-module' => 'SomeVendor\\TestingModule\\Facades\\TestingModule', - 'some' => 'SomeVendor\\TestingModule\\Facades\\Some', - ], - ]); -}); - -it('can update providers via ModuleConfigWriter', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/author', - ]); - - $module = $this->createModule([ - 'path' => $this->app->basePath('/modules/author'), - 'packageName' => 'laraneat/author', - 'name' => 'author', - 'namespace' => 'Modules\\Author', - 'providers' => [ - 'Modules\\Author\\Providers\\AuthorServiceProvider', - 'Modules\\Author\\Providers\\RouteServiceProvider', - ], - 'aliases' => [ - 'AuthorFacade' => 'Modules\\Author\\Facades\\SomeFacade', - ], - ]); - - $composerJsonPath = $module->subPath('composer.json'); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); - - /** @var ModuleConfigWriter $configWriter */ - $configWriter = $this->app->make(ModuleConfigWriter::class); - $configWriter->updateProviders($module, [ - 'Modules\\Author\\Providers\\FooServiceProvider', - 'Modules\\Foo\\Providers\\BarServiceProvider', - ]); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); -}); - -it('can update aliases via ModuleConfigWriter', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/author', - ]); - - $module = $this->createModule([ - 'path' => $this->app->basePath('/modules/author'), - 'packageName' => 'laraneat/author', - 'name' => 'author', - 'namespace' => 'Modules\\Author', - 'providers' => [ - 'Modules\\Author\\Providers\\AuthorServiceProvider', - 'Modules\\Author\\Providers\\RouteServiceProvider', - ], - 'aliases' => [ - 'AuthorFacade' => 'Modules\\Author\\Facades\\SomeFacade', - ], - ]); - - $composerJsonPath = $module->subPath('composer.json'); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); - - /** @var ModuleConfigWriter $configWriter */ - $configWriter = $this->app->make(ModuleConfigWriter::class); - $configWriter->updateAliases($module, [ - 'foo' => 'Modules\\Author\\Services\\Foo', - 'bar' => 'Modules\\Bar\\Services\\Bar', - ]); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); -}); - -it('can add providers via ModuleConfigWriter', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/author', - ]); - - $module = $this->createModule([ - 'path' => $this->app->basePath('/modules/author'), - 'packageName' => 'laraneat/author', - 'name' => 'author', - 'namespace' => 'Modules\\Author', - 'providers' => [ - 'Modules\\Author\\Providers\\AuthorServiceProvider', - 'Modules\\Author\\Providers\\RouteServiceProvider', - ], - 'aliases' => [ - 'AuthorFacade' => 'Modules\\Author\\Facades\\SomeFacade', - ], - ]); - - $composerJsonPath = $module->subPath('composer.json'); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); - - /** @var ModuleConfigWriter $configWriter */ - $configWriter = $this->app->make(ModuleConfigWriter::class); - $configWriter->addProvider($module, 'Modules\\Author\\Providers\\FooServiceProvider'); - $configWriter->addProvider($module, 'Modules\\Author\\Providers\\RouteServiceProvider'); // duplicate, should not be added - $configWriter->addProvider($module, 'Modules\\Foo\\Providers\\BarServiceProvider'); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); -}); - -it('can add aliases via ModuleConfigWriter', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/author', - ]); - - $module = $this->createModule([ - 'path' => $this->app->basePath('/modules/author'), - 'packageName' => 'laraneat/author', - 'name' => 'author', - 'namespace' => 'Modules\\Author', - 'providers' => [ - 'Modules\\Author\\Providers\\AuthorServiceProvider', - 'Modules\\Author\\Providers\\RouteServiceProvider', - ], - 'aliases' => [ - 'AuthorFacade' => 'Modules\\Author\\Facades\\SomeFacade', - ], - ]); - - $composerJsonPath = $module->subPath('composer.json'); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); - - /** @var ModuleConfigWriter $configWriter */ - $configWriter = $this->app->make(ModuleConfigWriter::class); - $configWriter->addAlias($module, 'foo', 'Modules\\Author\\Services\\Foo'); - $configWriter->addAlias($module, 'bar', 'Modules\\Bar\\Services\\Bar'); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); -}); diff --git a/tests/ModulesRepositoryTest.php b/tests/ModulesRepositoryTest.php deleted file mode 100644 index 85ea62b..0000000 --- a/tests/ModulesRepositoryTest.php +++ /dev/null @@ -1,476 +0,0 @@ -modulesManifestPath = $this->app->bootstrapPath('cache/testing-laraneat-modules.php'); - $this->repository = new ModulesRepository( - filesystem: $this->app['files'], - composer: $this->app[Composer::class], - modulesPath: $this->app['config']->get('modules.path'), - basePath: $this->app->basePath(), - modulesManifestPath: $this->modulesManifestPath, - ); -}); - -afterEach(function () { - $this->filesystem->delete($this->modulesManifestPath); -}); - -describe('scan paths', function () { - it('sets the initial scan paths from the configuration', function () { - expect($this->repository->getScanPaths())->toBe([ - $this->app->basePath('/modules/*'), - ]); - }); - - it('can add one scan path', function () { - $this->repository->addScanPath($this->app->basePath('/foo_bar')); - expect($this->repository->getScanPaths())->toBe([ - $this->app->basePath('/modules/*'), - $this->app->basePath('/foo_bar/*'), - ]); - }); - - it('can add array of scan paths', function () { - $this->repository->addScanPath([ - $this->app->basePath('/foo/bar/test folder'), - $this->app->basePath('/foo/test/'), - ]); - - expect($this->repository->getScanPaths())->toBe([ - $this->app->basePath('/modules/*'), - $this->app->basePath('/foo/bar/test folder/*'), - $this->app->basePath('/foo/test/*'), - ]); - }); - - it('normalizes the added paths', function () { - $this->repository->addScanPath([ - $this->app->basePath('/foo/bar/some1////'), - $this->app->basePath('/foo/bar/some2'), - $this->app->basePath('/foo/bar/some3/*'), - $this->app->basePath('/foo/bar/some4/*/'), - ]); - - expect($this->repository->getScanPaths())->toBe([ - $this->app->basePath('/modules/*'), - $this->app->basePath('/foo/bar/some1/*'), - $this->app->basePath('/foo/bar/some2/*'), - $this->app->basePath('/foo/bar/some3/*'), - $this->app->basePath('/foo/bar/some4/*'), - ]); - }); - - it('rejects adding a path that is already in the scan paths', function () { - $this->repository->addScanPath([ - $this->app->basePath('/modules'), - $this->app->basePath('/modules/'), - $this->app->basePath('/modules////'), - $this->app->basePath('/modules/*'), - $this->app->basePath('/modules/Nested'), - $this->app->basePath('/modules/Nested/'), - $this->app->basePath('/modules/Nested////'), - $this->app->basePath('/modules/Nested/*'), - $this->app->basePath('/foo/test/'), - $this->app->basePath('/foo/test////'), - ]); - - expect($this->repository->getScanPaths())->toBe([ - $this->app->basePath('/modules/*'), - $this->app->basePath('/modules/Nested/*'), - $this->app->basePath('/foo/test/*'), - ]); - }); -}); - -describe('modules manifest', function () { - it('can build modules manifest', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - $expectedManifest = [ - 'laraneat/article-category' => [ - 'path' => $this->app->basePath('/modules/article-category'), - 'name' => 'article-category', - 'namespace' => 'Modules\\ArticleCategory\\', - 'providers' => [ - 'Modules\\ArticleCategory\\Providers\\ArticleCategoryServiceProvider', - 'Modules\\ArticleCategory\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - 'laraneat/article' => [ - 'path' => $this->app->basePath('/modules/article'), - 'name' => 'article', - 'namespace' => 'Modules\\Article\\', - 'providers' => [ - 'Modules\\Article\\Providers\\ArticleServiceProvider', - 'Modules\\Article\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - 'laraneat/author' => [ - 'path' => $this->app->basePath('/modules/author'), - 'name' => 'author', - 'namespace' => 'Modules\\Author\\', - 'providers' => [ - 'Modules\\Author\\Providers\\AuthorServiceProvider', - 'Modules\\Author\\Providers\\RouteServiceProvider', - ], - 'aliases' => [ - 'AuthorFacade' => 'Modules\\Author\\Facades\\SomeFacade', - ], - ], - 'laraneat/empty' => [ - 'path' => $this->app->basePath('/modules/empty-module'), - 'name' => 'empty-module', - 'namespace' => 'Modules\\Empty\\', - 'providers' => [], - 'aliases' => [], - ], - 'laraneat/location' => [ - 'path' => $this->app->basePath('/modules/navigation'), - 'name' => 'navigation', - 'namespace' => 'Modules\\GeoLocation\\', - 'providers' => [ - 'Modules\\GeoLocation\\Providers\\GeoLocationServiceProvider', - 'Modules\\GeoLocation\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - ]; - - expect($this->repository->buildModulesManifest()) - ->toBe($expectedManifest) - ->and($this->modulesManifestPath)->toBeFile(); - }); - - it('throws an exception when modules have the same package names', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-copy', - ]); - - $this->repository->buildModulesManifest(); - })->throws(ModuleHasNonUniquePackageName::class); - - it('can prune modules manifest', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - $this->repository->buildModulesManifest(); - expect($this->modulesManifestPath)->toBeFile(); - $this->repository->pruneModulesManifest(); - expect($this->modulesManifestPath)->not->toBeFile(); - }); -}); - -it('can return modules as array', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - expect($this->repository->toArray())->toBe([ - 'laraneat/article-category' => [ - 'path' => $this->app->basePath('/modules/article-category'), - 'packageName' => 'laraneat/article-category', - 'name' => 'article-category', - 'namespace' => 'Modules\\ArticleCategory', - 'providers' => [ - 'Modules\\ArticleCategory\\Providers\\ArticleCategoryServiceProvider', - 'Modules\\ArticleCategory\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - 'laraneat/article' => [ - 'path' => $this->app->basePath('/modules/article'), - 'packageName' => 'laraneat/article', - 'name' => 'article', - 'namespace' => 'Modules\\Article', - 'providers' => [ - 'Modules\\Article\\Providers\\ArticleServiceProvider', - 'Modules\\Article\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - 'laraneat/author' => [ - 'path' => $this->app->basePath('/modules/author'), - 'packageName' => 'laraneat/author', - 'name' => 'author', - 'namespace' => 'Modules\\Author', - 'providers' => [ - 'Modules\\Author\\Providers\\AuthorServiceProvider', - 'Modules\\Author\\Providers\\RouteServiceProvider', - ], - 'aliases' => [ - 'AuthorFacade' => 'Modules\\Author\\Facades\\SomeFacade', - ], - ], - 'laraneat/empty' => [ - 'path' => $this->app->basePath('/modules/empty-module'), - 'packageName' => 'laraneat/empty', - 'name' => 'empty-module', - 'namespace' => 'Modules\\Empty', - 'providers' => [], - 'aliases' => [], - ], - 'laraneat/location' => [ - 'path' => $this->app->basePath('/modules/navigation'), - 'packageName' => 'laraneat/location', - 'name' => 'navigation', - 'namespace' => 'Modules\\GeoLocation', - 'providers' => [ - 'Modules\\GeoLocation\\Providers\\GeoLocationServiceProvider', - 'Modules\\GeoLocation\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - ]); -}); - -it('can check the module for existence', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - expect($this->repository->has('laraneat/foo'))->toBe(false) - ->and($this->repository->has('laraneat/article'))->toBe(true) - ->and($this->repository->has('laraneat/author'))->toBe(true) - ->and($this->repository->has('laraneat/location'))->toBe(true) - ->and($this->repository->has('laraneat/navigation'))->toBe(false) - ->and($this->repository->has('laraneat/book'))->toBe(false) - ->and($this->repository->has('laraneat/author/some'))->toBe(false) - ->and($this->repository->has('laraneat'))->toBe(false); -}); - -it('can count the number of modules', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - expect($this->repository->count())->toBe(5); -}); - -it('can find a module', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - expect($this->repository->find('laraneat/article')?->toArray())->toBe([ - 'path' => $this->app->basePath('/modules/article'), - 'packageName' => 'laraneat/article', - 'name' => 'article', - 'namespace' => 'Modules\\Article', - 'providers' => [ - 'Modules\\Article\\Providers\\ArticleServiceProvider', - 'Modules\\Article\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ]) - ->and($this->repository->find('laraneat')?->toArray())->toBe(null) - ->and($this->repository->find('laraneat/article/bar')?->toArray())->toBe(null) - ->and($this->repository->find('laraneat/location')?->toArray())->toBe([ - 'path' => $this->app->basePath('/modules/navigation'), - 'packageName' => 'laraneat/location', - 'name' => 'navigation', - 'namespace' => 'Modules\\GeoLocation', - 'providers' => [ - 'Modules\\GeoLocation\\Providers\\GeoLocationServiceProvider', - 'Modules\\GeoLocation\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ]) - ->and($this->repository->find('laraneat/navigation')?->toArray())->toBe(null) - ->and($this->repository->find('laraneat/book')?->toArray())->toBe(null); -}); - -it('throws an exception when the module is not found', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - $this->repository->findOrFail('laraneat/book'); -})->throws(ModuleNotFound::class); - -it('throws an exception when trying to remove a module that does not exist', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - expect($this->repository->delete('laraneat/book')); -})->throws(ModuleNotFound::class); - -it('can filter modules by name', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - $articleModuleMatch = [ - 'laraneat/article' => [ - 'path' => $this->app->basePath('/modules/article'), - 'packageName' => 'laraneat/article', - 'name' => 'article', - 'namespace' => 'Modules\\Article', - 'providers' => [ - 'Modules\\Article\\Providers\\ArticleServiceProvider', - 'Modules\\Article\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - ]; - - $locationModuleMatch = [ - 'laraneat/location' => [ - 'path' => $this->app->basePath('/modules/navigation'), - 'packageName' => 'laraneat/location', - 'name' => 'navigation', - 'namespace' => 'Modules\\GeoLocation', - 'providers' => [ - 'Modules\\GeoLocation\\Providers\\GeoLocationServiceProvider', - 'Modules\\GeoLocation\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - ]; - - expect(collect($this->repository->filterByName('article'))->toArray())->toBe($articleModuleMatch) - ->and(collect($this->repository->filterByName('Article'))->toArray())->toBe($articleModuleMatch) - ->and(collect($this->repository->filterByName('ARTICLE'))->toArray())->toBe([]) - ->and(collect($this->repository->filterByName('aarticle'))->toArray())->toBe([]) - ->and(collect($this->repository->filterByName('location'))->toArray())->toBe($locationModuleMatch) - ->and(collect($this->repository->filterByName('navigation'))->toArray())->toBe($locationModuleMatch) - ->and(collect($this->repository->filterByName('Navigation'))->toArray())->toBe($locationModuleMatch) - ->and(collect($this->repository->filterByName('GeoLocation'))->toArray())->toBe([]) - ->and(collect($this->repository->filterByName('foo'))->toArray())->toBe([]) - ->and(collect($this->repository->filterByName('laraneat'))->toArray())->toBe([]); - -}); - -it('throws an exception when modules with the requested name are not found', function () { - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - $articleModuleMatch = [ - 'laraneat/article' => [ - 'path' => $this->app->basePath('/modules/article'), - 'packageName' => 'laraneat/article', - 'name' => 'article', - 'namespace' => 'Modules\\Article', - 'providers' => [ - 'Modules\\Article\\Providers\\ArticleServiceProvider', - 'Modules\\Article\\Providers\\RouteServiceProvider', - ], - 'aliases' => [], - ], - ]; - - expect(collect($this->repository->filterByNameOrFail('article'))->toArray())->toBe($articleModuleMatch); - expect(fn () => $this->repository->filterByNameOrFail('book'))->toThrow(ModuleNotFound::class); -}); - -it('can delete a module', function () { - // Set mock BEFORE setModules, because Module instances get Composer from container - $this->instance(Composer::class, $this->mockComposer(['removePackages' => true])); - - // Recreate repository with mocked Composer - $this->repository = new ModulesRepository( - filesystem: $this->app['files'], - composer: $this->app[Composer::class], - modulesPath: $this->app['config']->get('modules.path'), - basePath: $this->app->basePath(), - modulesManifestPath: $this->modulesManifestPath, - ); - - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - expect($this->repository->has('laraneat/article'))->toBe(true) - ->and($this->repository->delete('laraneat/article'))->toBe(true) - ->and($this->repository->has('laraneat/article'))->toBe(false); -}); - -it('can sync modules with composer', function () { - // Set mock BEFORE setModules, because repository uses Composer from container - $this->instance(Composer::class, $this->mockComposer(['updatePackages' => true])); - - // Recreate repository with mocked Composer - $this->repository = new ModulesRepository( - filesystem: $this->app['files'], - composer: $this->app[Composer::class], - modulesPath: $this->app['config']->get('modules.path'), - basePath: $this->app->basePath(), - modulesManifestPath: $this->modulesManifestPath, - ); - - $this->setModules([ - __DIR__ . '/fixtures/stubs/modules/valid/article', - __DIR__ . '/fixtures/stubs/modules/valid/article-category', - __DIR__ . '/fixtures/stubs/modules/valid/author', - __DIR__ . '/fixtures/stubs/modules/valid/empty-module', - __DIR__ . '/fixtures/stubs/modules/valid/navigation', - ]); - - $this->backupComposerJson(); - $composerJsonPath = $this->app->basePath('/composer.json'); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); - $this->repository->syncWithComposer(); - assertFileExists($composerJsonPath); - assertMatchesFileSnapshot($composerJsonPath); - $this->resetComposerJson(); -}); diff --git a/tests/Pest.php b/tests/Pest.php deleted file mode 100644 index 8b26e9c..0000000 --- a/tests/Pest.php +++ /dev/null @@ -1,55 +0,0 @@ - - */ -function getRelativeFilePathsInDirectory(string $directory): array -{ - $filesystem = new Illuminate\Filesystem\Filesystem(); - - return array_map(fn (SplFileInfo $fileInfo) => $fileInfo->getRelativePathname(), $filesystem->allFiles($directory)); -} - -/** - * @param array $paths - * @param string $basePath - * @return array - */ -function makeAbsolutePaths(array $paths, string $basePath): array -{ - $basePath = rtrim($basePath, '\\/'); - - return array_map(fn (string $path) => realpath($basePath . '/' . $path), $paths); -} - -function assertsMatchesDirectorySnapshot(string $directory): void -{ - expect($directory)->toBeDirectory(); - $filePaths = getRelativeFilePathsInDirectory($directory); - assertMatchesSnapshot($filePaths); - foreach (makeAbsolutePaths($filePaths, $directory) as $filePath) { - assertMatchesFileSnapshot($filePath); - } -} - -uses(Laraneat\Modules\Tests\TestCase::class) - ->afterEach(function () { - /** @var \Laraneat\Modules\Tests\TestCase $this */ - $this->pruneModulesPaths(); - $this->resetComposerJson(); - }) - ->in('.'); diff --git a/tests/Support/ComposerJsonFileTest.php b/tests/Support/ComposerJsonFileTest.php deleted file mode 100644 index 889c295..0000000 --- a/tests/Support/ComposerJsonFileTest.php +++ /dev/null @@ -1,85 +0,0 @@ - $content - * @return array - */ -function addModuleToComposerJson(array $content, string $packageName = 'app/blog', int $times = 1): array -{ - $path = sys_get_temp_dir() . '/laraneat-composer-' . uniqid() . '.json'; - file_put_contents($path, json_encode($content, JSON_PRETTY_PRINT)); - - try { - for ($i = 0; $i < $times; $i++) { - ComposerJsonFile::create($path)->addModule($packageName, 'modules/blog')->save(); - } - - return json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); - } finally { - @unlink($path); - } -} - -it('adds the module, its path repository and excludes its vendor from packagist', function () { - $composerJson = addModuleToComposerJson(['name' => 'demo/app', 'require' => ['php' => '^8.3']]); - - expect($composerJson['require'])->toBe(['php' => '^8.3', 'app/blog' => '*']) - ->and($composerJson['repositories'])->toBe([ - ['type' => 'path', 'url' => 'modules/*', 'options' => ['symlink' => true]], - ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['app/*']], - ['packagist.org' => false], - ]); -}); - -it('is idempotent', function () { - $once = addModuleToComposerJson(['name' => 'demo/app']); - $twice = addModuleToComposerJson(['name' => 'demo/app'], times: 2); - - expect($twice)->toBe($once); -}); - -it('extends an existing packagist repository instead of adding another one', function () { - $composerJson = addModuleToComposerJson([ - 'repositories' => [ - ['type' => 'composer', 'url' => 'https://repo.packagist.org/', 'exclude' => ['other/*']], - ['packagist.org' => false], - ], - ]); - - expect($composerJson['repositories'])->toBe([ - ['type' => 'composer', 'url' => 'https://repo.packagist.org/', 'exclude' => ['other/*', 'app/*']], - ['packagist.org' => false], - ['type' => 'path', 'url' => 'modules/*', 'options' => ['symlink' => true]], - ]); -}); - -it('leaves a packagist repository restricted with "only" untouched', function () { - $repository = ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'only' => ['laravel/*']]; - $composerJson = addModuleToComposerJson(['repositories' => [$repository, ['packagist.org' => false]]]); - - expect($composerJson['repositories'][0])->toBe($repository); -}); - -it('does not re-enable packagist when it is disabled', function () { - $composerJson = addModuleToComposerJson(['repositories' => [['packagist.org' => false]]]); - - expect($composerJson['repositories'])->toBe([ - ['packagist.org' => false], - ['type' => 'path', 'url' => 'modules/*', 'options' => ['symlink' => true]], - ]); -}); - -it('supports repositories defined as an object', function () { - $composerJson = addModuleToComposerJson([ - 'repositories' => ['private' => ['type' => 'composer', 'url' => 'https://repo.example.com']], - ]); - - expect($composerJson['repositories'])->toBe([ - 'private' => ['type' => 'composer', 'url' => 'https://repo.example.com'], - 0 => ['type' => 'path', 'url' => 'modules/*', 'options' => ['symlink' => true]], - 'packagist' => ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['app/*']], - 'packagist.org' => false, - ]); -}); diff --git a/tests/Support/ComposerTest.php b/tests/Support/ComposerTest.php deleted file mode 100644 index 1dea8d7..0000000 --- a/tests/Support/ComposerTest.php +++ /dev/null @@ -1,59 +0,0 @@ -> */ - public array $commands = []; - - protected function getProcess(array $command, array $env = []) - { - $this->commands[] = $command; - - return new Process([PHP_BINARY, '-r', 'exit(0);']); - } - }; -} - -it('passes package names after "--" so they can never be parsed as options', function (string $method, string $action) { - $composer = recordingComposer(); - - expect($composer->{$method}(['vendor/module-a', 'vendor/module-b']))->toBeTrue() - ->and(array_slice($composer->commands[0], -4))->toBe([$action, '--', 'vendor/module-a', 'vendor/module-b']); -})->with([ - ['updatePackages', 'update'], - ['removePackages', 'remove'], -]); - -it('rejects invalid package names without running composer', function (string $packageName) { - $composer = recordingComposer(); - - expect(fn () => $composer->updatePackages([$packageName]))->toThrow(ComposerException::class) - ->and(fn () => $composer->removePackages([$packageName]))->toThrow(ComposerException::class) - ->and($composer->commands)->toBe([]); -})->with([ - '--no-plugins', - '-vvv/module', - 'vendor', - 'Vendor/Module', - 'vendor/module name', - 'vendor/../module', - 'evil","scripts":{"x":"y"},"a":"/blog', -]); - -it('validates package names with the composer rules', function (string $packageName, bool $valid) { - expect(Composer::isValidPackageName($packageName))->toBe($valid); -})->with([ - ['laraneat/article', true], - ['my-vendor/article-category', true], - ['vendor.name/module_name', true], - ['vendor/module--double', true], - ['vendor/module-', false], - ['vendor/Module', false], - ["vendor/module\n", false], -]); diff --git a/tests/Support/Concerns/CanLoadRoutesFromDirectoryTest.php b/tests/Support/Concerns/CanLoadRoutesFromDirectoryTest.php deleted file mode 100644 index 653fded..0000000 --- a/tests/Support/Concerns/CanLoadRoutesFromDirectoryTest.php +++ /dev/null @@ -1,41 +0,0 @@ -routesDirectory = sys_get_temp_dir() . '/laraneat-routes-' . uniqid(); - @mkdir($this->routesDirectory . '/v1', 0777, true); - - file_put_contents($this->routesDirectory . '/list_articles.php', " 'ok')->name('articles.list');\n"); - file_put_contents($this->routesDirectory . '/v1/view_article.php', " 'ok')->name('articles.view');\n"); - file_put_contents($this->routesDirectory . '/list_articles.php.orig', " 'ok')->name('articles.old');\n"); - file_put_contents($this->routesDirectory . '/README.md', 'This text must never be echoed.'); -}); - -afterEach(function () { - $this->app['files']->deleteDirectory($this->routesDirectory); -}); - -it('loads route files and prefixes nested directories', function () { - $loader = new class () { - use CanLoadRoutesFromDirectory; - - public function load(string $directory): void - { - $this->loadRoutesFromDirectory($directory); - } - }; - - ob_start(); - Route::prefix('api')->group(fn () => $loader->load($this->routesDirectory)); - $output = ob_get_clean(); - - $routes = Route::getRoutes(); - $routes->refreshNameLookups(); - - expect($output)->toBe('') - ->and($routes->getByName('articles.list')?->uri())->toBe('api/articles') - ->and($routes->getByName('articles.view')?->uri())->toBe('api/v1/articles/{id}') - ->and($routes->getByName('articles.old'))->toBeNull(); -}); diff --git a/tests/Support/Concerns/CanRunModuleSeedersTest.php b/tests/Support/Concerns/CanRunModuleSeedersTest.php deleted file mode 100644 index 062b6a4..0000000 --- a/tests/Support/Concerns/CanRunModuleSeedersTest.php +++ /dev/null @@ -1,82 +0,0 @@ - */ - public array $ranSeeders = []; - - public function call($class, $silent = false, array $parameters = []) - { - $this->ranSeeders[] = $class; - - return $this; - } - }; -} - -beforeEach(function () { - $this->setModules([ - __DIR__ . '/../../fixtures/stubs/modules/valid/author', - __DIR__ . '/../../fixtures/stubs/modules/valid/navigation', - ], $this->app->basePath('/modules')); - - $author = $this->app->basePath('/modules/author/database/seeders'); - writeSeeder("$author/AuthorPermissionsSeeder_1.php", 'Modules\\Author\\Database\\Seeders', 'AuthorPermissionsSeeder_1'); - writeSeeder("$author/AuthorSeeder_10.php", 'Modules\\Author\\Database\\Seeders', 'AuthorSeeder_10'); - writeSeeder("$author/AuthorDataSeeder.php", 'Modules\\Author\\Database\\Seeders', 'AuthorDataSeeder'); - writeSeeder("$author/Deployment/AuthorDeploySeeder_2.php", 'Modules\\Author\\Database\\Seeders\\Deployment', 'AuthorDeploySeeder_2'); - writeSeeder("$author/Testing/AuthorTestingSeeder_3.php", 'Modules\\Author\\Database\\Seeders\\Testing', 'AuthorTestingSeeder_3'); - file_put_contents("$author/README.md", 'not a seeder'); - file_put_contents("$author/AuthorOldSeeder_1.php.orig", 'app->basePath('/modules/navigation/database/seeders'); - writeSeeder("$navigation/NavigationPermissionsSeeder_1.php", 'Modules\\Navigation\\Database\\Seeders', 'NavigationPermissionsSeeder_1'); - writeSeeder("$navigation/NavigationSeeder_2.php", 'Modules\\Navigation\\Database\\Seeders', 'NavigationSeeder_2'); -}); - -it('runs root seeders of all modules ordered by their integer suffix', function () { - $seeder = recordingModuleSeeder(); - $seeder->runSeedersFromModules(); - - expect($seeder->ranSeeders)->toBe([ - 'Modules\\Author\\Database\\Seeders\\AuthorPermissionsSeeder_1', - 'Modules\\Navigation\\Database\\Seeders\\NavigationPermissionsSeeder_1', - 'Modules\\Navigation\\Database\\Seeders\\NavigationSeeder_2', - 'Modules\\Author\\Database\\Seeders\\AuthorSeeder_10', - 'Modules\\Author\\Database\\Seeders\\AuthorDataSeeder', - ]); -}); - -it('includes every requested subdirectory, not only the first one', function (array $subdirectories) { - $seeder = recordingModuleSeeder(); - $seeder->runSeedersFromModules($subdirectories); - - expect($seeder->ranSeeders) - ->toContain('Modules\\Author\\Database\\Seeders\\Deployment\\AuthorDeploySeeder_2') - ->toContain('Modules\\Author\\Database\\Seeders\\Testing\\AuthorTestingSeeder_3') - ->toContain('Modules\\Author\\Database\\Seeders\\AuthorPermissionsSeeder_1') - ->and($seeder->ranSeeders)->toHaveCount(7) - ->and(array_unique($seeder->ranSeeders))->toHaveCount(7); -})->with([ - 'without root' => [['Deployment', 'Testing']], - 'with root' => [['/', '/Deployment', '/Testing']], -]); - -it('ignores files that are not php files', function () { - $seeder = recordingModuleSeeder(); - $seeder->runSeedersFromModules(); - - expect($seeder->ranSeeders)->not->toContain('Old\\AuthorOldSeeder_1'); -}); diff --git a/tests/Support/Generator/GeneratorHelperTest.php b/tests/Support/Generator/GeneratorHelperTest.php deleted file mode 100644 index e9bda89..0000000 --- a/tests/Support/Generator/GeneratorHelperTest.php +++ /dev/null @@ -1,134 +0,0 @@ -toBe('src/some/Actions'); - expect(GeneratorHelper::normalizePath('/src/some/Actions'))->toBe('src/some/Actions'); - expect(GeneratorHelper::normalizePath('/src/some\\Actions/'))->toBe('src/some/Actions'); - expect(GeneratorHelper::normalizePath('/src/some/Actions///'))->toBe('src/some/Actions'); - expect(GeneratorHelper::normalizePath('////src/some/Actions\\'))->toBe('src/some/Actions'); - expect(GeneratorHelper::normalizePath('////src\\some/Actions////'))->toBe('src/some/Actions'); - expect(GeneratorHelper::normalizePath('\\src\\some\\Actions'))->toBe('src/some/Actions'); - expect(GeneratorHelper::normalizePath('\\\\src\\some\\Actions\\\\'))->toBe('src/some/Actions'); - }); - - it('correctly normalizes path using rtrim', function () { - expect(GeneratorHelper::normalizePath('src/some/Actions', true))->toBe('src/some/Actions'); - expect(GeneratorHelper::normalizePath('/src/some/Actions', true))->toBe('/src/some/Actions'); - expect(GeneratorHelper::normalizePath('/src/some\\Actions/', true))->toBe('/src/some/Actions'); - expect(GeneratorHelper::normalizePath('/src/some/Actions///', true))->toBe('/src/some/Actions'); - expect(GeneratorHelper::normalizePath('////src/some/Actions\\', true))->toBe('/src/some/Actions'); - expect(GeneratorHelper::normalizePath('////src\\some/Actions////', true))->toBe('/src/some/Actions'); - expect(GeneratorHelper::normalizePath('\\src\\some\\Actions', true))->toBe('/src/some/Actions'); - expect(GeneratorHelper::normalizePath('\\\\src\\some\\Actions\\\\', true))->toBe('/src/some/Actions'); - }); -}); - -describe('normalizeNamespace()', function () { - it('correctly normalizes namespace', function () { - expect(GeneratorHelper::normalizeNamespace('src/some/Actions'))->toBe('src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('/src/some/Actions'))->toBe('src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('/src/some\\Actions/'))->toBe('src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('/src/some/Actions///'))->toBe('src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('////src/some/Actions\\'))->toBe('src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('////src\\some/Actions////'))->toBe('src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('\\src\\some\\Actions'))->toBe('src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('\\\\src\\some\\Actions\\\\'))->toBe('src\\some\\Actions'); - }); - - it('correctly normalizes namespace using rtrim', function () { - expect(GeneratorHelper::normalizeNamespace('src/some/Actions', true))->toBe('src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('/src/some/Actions', true))->toBe('\\src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('/src/some\\Actions/', true))->toBe('\\src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('/src/some/Actions///', true))->toBe('\\src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('////src/some/Actions\\', true))->toBe('\\src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('////src\\some/Actions////', true))->toBe('\\src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('\\src\\some\\Actions', true))->toBe('\\src\\some\\Actions'); - expect(GeneratorHelper::normalizeNamespace('\\\\src\\some\\Actions\\\\', true))->toBe('\\src\\some\\Actions'); - }); -}); - -describe('makeRelativePath()', function () { - it('correctly make relative', function () { - expect(GeneratorHelper::makeRelativePath('', ''))->toBe(''); - expect(GeneratorHelper::makeRelativePath('/', ''))->toBe(null); - expect(GeneratorHelper::makeRelativePath('', '/'))->toBe(null); - expect(GeneratorHelper::makeRelativePath('src/some/foo/bar', 'src/some/foo/bar'))->toBe(''); - expect(GeneratorHelper::makeRelativePath('/src/some/foo/bar', 'src/some/foo/bar'))->toBe(null); - expect(GeneratorHelper::makeRelativePath('src/some/foo/bar', '/src/some/foo/bar'))->toBe(null); - expect(GeneratorHelper::makeRelativePath('src/some/foo/bar', 'src/some'))->toBe('../..'); - expect(GeneratorHelper::makeRelativePath('src/some', 'src/some/foo/bar'))->toBe('foo/bar'); - expect(GeneratorHelper::makeRelativePath('/src/some', '/src/some/foo/bar'))->toBe('foo/bar'); - expect(GeneratorHelper::makeRelativePath('src/some/bar', 'src/some/foo/bar'))->toBe('../foo/bar'); - expect(GeneratorHelper::makeRelativePath('src/some/foo/bar', 'src/some/bar'))->toBe('../../bar'); - expect(GeneratorHelper::makeRelativePath('src/some/bar/baz', 'src/some/foo/bar/baz'))->toBe('../../foo/bar/baz'); - expect(GeneratorHelper::makeRelativePath('src/some/foo/bar/baz', 'src/some/bar/baz'))->toBe('../../../bar/baz'); - expect(GeneratorHelper::makeRelativePath('/src/some/foo/bar/baz', '/src/some/bar/baz'))->toBe('../../../bar/baz'); - expect(GeneratorHelper::makeRelativePath('/src/some/foo/bar/baz', '////src/some/bar/baz'))->toBe('../../../bar/baz'); - expect(GeneratorHelper::makeRelativePath('/////src/some/foo/bar/baz', '/src/some/bar/baz'))->toBe('../../../bar/baz'); - expect(GeneratorHelper::makeRelativePath('foo/bar/baz', 'src/some/bar/baz'))->toBe(null); - }); -}); - -describe('getBasePath()', function () { - it('returns base path', function () { - $this->app['config']->set('modules.path', $this->app->basePath('/foo/bar')); - - expect(GeneratorHelper::getBasePath())->toBe($this->app->basePath('/foo/bar')); - - $this->app['config']->set('modules.path', $this->app->basePath('/foo/bar/') . '///'); - - expect(GeneratorHelper::getBasePath())->toBe($this->app->basePath('/foo/bar')); - }); - - it('throws an error when the modules path is not defined', function () { - $this->app['config']->set('modules.path', ''); - - expect(fn () => GeneratorHelper::getBasePath())->toThrow(InvalidConfigValue::class); - }); -}); - -describe('getBaseNamespace()', function () { - it('returns base namespace', function () { - $this->app['config']->set('modules.namespace', '\\Foo\\Bar\\'); - - expect(GeneratorHelper::getBaseNamespace())->toBe('Foo\\Bar'); - }); - - it('throws an error when the modules namespace is not defined', function () { - $this->app['config']->set('modules.namespace', ''); - - expect(fn () => GeneratorHelper::getBaseNamespace())->toThrow(InvalidConfigValue::class); - }); -}); - -describe('component(ModuleComponentType $componentType)', function () { - it('returns component config by type', function () { - $this->app['config']->set('modules.components.' . ModuleComponentType::Action->value, [ - 'path' => '///Some/Actions///', - ]); - - expect(GeneratorHelper::component(ModuleComponentType::Action)->getPath())->toBe('Some/Actions'); - expect(GeneratorHelper::component(ModuleComponentType::Action)->getNamespace())->toBe('Some\\Actions'); - - $this->app['config']->set('modules.components.' . ModuleComponentType::Action->value, [ - 'path' => '///src/Some/Actions///', - 'namespace' => '\\\\\\Some\\Actions\\\\\\', - ]); - - expect(GeneratorHelper::component(ModuleComponentType::Action)->getPath())->toBe('src/Some/Actions'); - expect(GeneratorHelper::component(ModuleComponentType::Action)->getNamespace())->toBe('Some\\Actions'); - }); - - it('throws an error when the component has invalid config', function () { - $this->app['config']->set('modules.components.' . ModuleComponentType::Action->value, 'foo'); - expect(fn () => GeneratorHelper::component(ModuleComponentType::Action))->toThrow(InvalidConfigValue::class); - - $this->app['config']->set('modules.components.' . ModuleComponentType::Action->value, ['namespace' => 'Some\\Actions']); - expect(fn () => GeneratorHelper::component(ModuleComponentType::Action))->toThrow(InvalidConfigValue::class); - }); -}); diff --git a/tests/TestCase.php b/tests/TestCase.php deleted file mode 100644 index 814ae6e..0000000 --- a/tests/TestCase.php +++ /dev/null @@ -1,208 +0,0 @@ -resolveDefaultApplication(); - } - - protected function setUp(): void - { - parent::setUp(); - - $this->filesystem = $this->app['files']; - } - - protected function tearDown(): void - { - parent::tearDown(); - - Mockery::close(); - } - - protected function getPackageProviders($app): array - { - return [ - ComposerServiceProvider::class, - ConsoleServiceProvider::class, - ModulesRepositoryServiceProvider::class, - ModulesServiceProvider::class, - ]; - } - - protected function getPackageAliases($app): array - { - return [ - 'Modules' => Modules::class, - ]; - } - - /** - * @param Application $app - */ - protected function getEnvironmentSetUp($app): void - { - $app['config']->set('database.default', 'sqlite'); - $app['config']->set('database.connections.sqlite', [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ]); - } - - /** - * @param string[] $paths - */ - public function setModules(array $paths, ?string $modulesPath = null): void - { - $modulesPath = rtrim($modulesPath ?? GeneratorHelper::getBasePath(), '/'); - $this->addModulesPath($modulesPath); - $this->filesystem->ensureDirectoryExists($modulesPath); - - foreach ($paths as $modulePath) { - $modulePath = rtrim($modulePath, '/'); - $this->filesystem->copyDirectory($modulePath, $modulesPath . '/' . Str::afterLast($modulePath, '/')); - } - - $this->app[ModulesRepository::class]->pruneModulesManifest(); - } - - public function pruneModulesPaths(): void - { - if ($this->modulesPaths) { - foreach ($this->modulesPaths as $path) { - if ($this->filesystem->isDirectory($path)) { - $this->filesystem->deleteDirectory($path); - } - } - $this->modulesPaths = []; - $this->app[ModulesRepository::class]->pruneModulesManifest(); - } - } - - public function backupComposerJson(): void - { - if ($this->composerJsonBackupPath !== null) { - return; - } - - $this->composerJsonBackupPath = $this->app->basePath('/composer.backup.json'); - if ($this->filesystem->isFile($this->composerJsonBackupPath)) { - $this->resetComposerJson(); - $this->composerJsonBackupPath = $this->app->basePath('/composer.backup.json'); - } - $this->filesystem->copy($this->app->basePath('/composer.json'), $this->composerJsonBackupPath); - } - - public function resetComposerJson(): void - { - if ($this->composerJsonBackupPath === null) { - return; - } - - if ($this->filesystem->isFile($this->composerJsonBackupPath)) { - $this->filesystem->delete($this->app->basePath('/composer.json')); - $this->filesystem->copy($this->composerJsonBackupPath, $this->app->basePath('/composer.json')); - $this->filesystem->delete($this->composerJsonBackupPath); - } - $this->composerJsonBackupPath = null; - } - - /** - * @param array $attributes - */ - public function createModule(array $attributes = []): Module - { - return new Module( - packageName: $attributes['packageName'] ?? 'some-vendor/testing-module', - name: $attributes['name'] ?? '', - path: $attributes['path'] ?? $this->app->basePath('modules/TestingModule'), - namespace: $attributes['namespace'] ?? 'SomeVendor\\TestingModule\\', - providers: $attributes['providers'] ?? [ - 'SomeVendor\\TestingModule\\Providers\\TestingModuleServiceProvider', - 'SomeVendor\\TestingModule\\Providers\\RouteServiceProvider', - ], - aliases: $attributes['aliases'] ?? [ - 'testing-module' => 'SomeVendor\\TestingModule\\Facades\\TestingModule', - ], - ); - } - - /** - * Create a mock of Composer class for testing. - * - * @param array $methodExpectations Array of method names to their expected return values - * - * @return MockInterface&Composer - */ - public function mockComposer(array $methodExpectations = []): MockInterface - { - $composer = Mockery::mock(Composer::class); - - foreach ($methodExpectations as $method => $returnValue) { - $composer->shouldReceive($method)->andReturn($returnValue); - } - - return $composer; - } - - private function addModulesPath(string $path): void - { - if (in_array($path, $this->modulesPaths, true)) { - return; - } - - if ($this->filesystem->isDirectory($path)) { - $this->filesystem->deleteDirectories($path); - } - - $this->modulesPaths[] = $path; - } -} diff --git a/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__api__module__2.yml b/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__api__module__2.yml deleted file mode 100644 index 386aaae..0000000 --- a/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__api__module__2.yml +++ /dev/null @@ -1,33 +0,0 @@ -- composer.json -- database/factories/ArticleCommentFactory.php -- database/migrations/2024_01_01_000000_create_article_comments_table.php -- database/seeders/ArticleCommentPermissionsSeeder_1.php -- src/Actions/CreateArticleCommentAction.php -- src/Actions/DeleteArticleCommentAction.php -- src/Actions/ListArticleCommentsAction.php -- src/Actions/UpdateArticleCommentAction.php -- src/Actions/ViewArticleCommentAction.php -- src/DTO/CreateArticleCommentDTO.php -- src/DTO/UpdateArticleCommentDTO.php -- src/Models/ArticleComment.php -- src/Policies/ArticleCommentPolicy.php -- src/Providers/ArticleCommentServiceProvider.php -- src/Providers/RouteServiceProvider.php -- src/UI/API/QueryWizards/ArticleCommentQueryWizard.php -- src/UI/API/QueryWizards/ArticleCommentsQueryWizard.php -- src/UI/API/Requests/CreateArticleCommentRequest.php -- src/UI/API/Requests/DeleteArticleCommentRequest.php -- src/UI/API/Requests/ListArticleCommentsRequest.php -- src/UI/API/Requests/UpdateArticleCommentRequest.php -- src/UI/API/Requests/ViewArticleCommentRequest.php -- src/UI/API/Resources/ArticleCommentResource.php -- src/UI/API/routes/v1/create_article_comment.php -- src/UI/API/routes/v1/delete_article_comment.php -- src/UI/API/routes/v1/list_article_comments.php -- src/UI/API/routes/v1/update_article_comment.php -- src/UI/API/routes/v1/view_article_comment.php -- tests/UI/API/CreateArticleCommentTest.php -- tests/UI/API/DeleteArticleCommentTest.php -- tests/UI/API/ListArticleCommentsTest.php -- tests/UI/API/UpdateArticleCommentTest.php -- tests/UI/API/ViewArticleCommentTest.php diff --git a/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__base__module__2.yml b/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__base__module__2.yml deleted file mode 100644 index d03888b..0000000 --- a/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__base__module__2.yml +++ /dev/null @@ -1,8 +0,0 @@ -- composer.json -- database/factories/ArticleCommentFactory.php -- database/migrations/2024_01_01_000000_create_article_comments_table.php -- database/seeders/ArticleCommentPermissionsSeeder_1.php -- src/Models/ArticleComment.php -- src/Policies/ArticleCommentPolicy.php -- src/Providers/ArticleCommentServiceProvider.php -- src/Providers/RouteServiceProvider.php diff --git a/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__plain__module__2.yml b/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__plain__module__2.yml deleted file mode 100644 index 173b667..0000000 --- a/tests/__snapshots__/ModuleMakeCommandTest__it_generates_a__plain__module__2.yml +++ /dev/null @@ -1,3 +0,0 @@ -- composer.json -- src/Providers/ArticleCommentServiceProvider.php -- src/Providers/RouteServiceProvider.php diff --git a/tests/__snapshots__/StubPublishCommandTest__it_publish_all_laraneat_modules_stubs__1.yml b/tests/__snapshots__/StubPublishCommandTest__it_publish_all_laraneat_modules_stubs__1.yml deleted file mode 100644 index 2d90c51..0000000 --- a/tests/__snapshots__/StubPublishCommandTest__it_publish_all_laraneat_modules_stubs__1.yml +++ /dev/null @@ -1,63 +0,0 @@ -- action/create.stub -- action/delete.stub -- action/list.stub -- action/plain.stub -- action/update.stub -- action/view.stub -- command.stub -- composer.json.stub -- controller/api.stub -- controller/web.stub -- dto.stub -- event.stub -- exception.stub -- factory.stub -- job/plain.stub -- job/queued.stub -- listener/plain.stub -- listener/queued.stub -- mail.stub -- middleware.stub -- migration/add.stub -- migration/create.stub -- migration/delete.stub -- migration/pivot.stub -- migration/plain.stub -- model/full.stub -- model/plain.stub -- notification/plain.stub -- notification/queued.stub -- observer.stub -- policy/full.stub -- policy/plain.stub -- provider/event.stub -- provider/module.stub -- provider/plain.stub -- provider/route.stub -- query-wizard/eloquent.stub -- query-wizard/model.stub -- request/create.stub -- request/delete.stub -- request/list.stub -- request/plain.stub -- request/update.stub -- request/view.stub -- resource/collection.stub -- resource/single.stub -- route.stub -- rule.stub -- seeder/permissions.stub -- seeder/plain.stub -- test/api/create.stub -- test/api/delete.stub -- test/api/list.stub -- test/api/plain.stub -- test/api/update.stub -- test/api/view.stub -- test/cli/plain.stub -- test/feature/plain.stub -- test/unit/plain.stub -- test/web/create.stub -- test/web/delete.stub -- test/web/plain.stub -- test/web/update.stub diff --git a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__create__action_for_the_module__1.php b/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__create__action_for_the_module__1.php deleted file mode 100644 index ed01161..0000000 --- a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__create__action_for_the_module__1.php +++ /dev/null @@ -1,27 +0,0 @@ -all()); - } - - public function asController(CreateAuthorRequest $request): JsonResponse - { - $author = $this->handle($request->toDTO()); - - return (new AuthorResource($author))->created(); - } -} diff --git a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__delete__action_for_the_module__1.php b/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__delete__action_for_the_module__1.php deleted file mode 100644 index 0f8b701..0000000 --- a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__delete__action_for_the_module__1.php +++ /dev/null @@ -1,25 +0,0 @@ -delete(); - } - - public function asController(DeleteAuthorRequest $request, Author $author): Response - { - $this->handle($author); - - return $this->noContent(); - } -} diff --git a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__list__action_for_the_module__1.php b/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__list__action_for_the_module__1.php deleted file mode 100644 index 1a51d18..0000000 --- a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__list__action_for_the_module__1.php +++ /dev/null @@ -1,28 +0,0 @@ -build() - ->paginate(); - } - - public function asController(ListAuthorsRequest $request): AnonymousResourceCollection - { - return AuthorResource::collection($this->handle($request)); - } -} diff --git a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__plain__action_for_the_module__1.php b/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__plain__action_for_the_module__1.php deleted file mode 100644 index 148c7ab..0000000 --- a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__plain__action_for_the_module__1.php +++ /dev/null @@ -1,15 +0,0 @@ -all(); - - if ($data) { - $author->update($data); - } - - return $author; - } - - public function asController(UpdateAuthorRequest $request, Author $author): AuthorResource - { - $author = $this->handle($author, $request->toDTO()); - - return new AuthorResource($author); - } -} diff --git a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__view__action_for_the_module__1.php b/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__view__action_for_the_module__1.php deleted file mode 100644 index 3443c4b..0000000 --- a/tests/__snapshots__/files/ActionMakeCommandTest__it_generates__view__action_for_the_module__1.php +++ /dev/null @@ -1,25 +0,0 @@ -build(); - } - - public function asController(ViewAuthorRequest $request, Author $author): AuthorResource - { - return new AuthorResource($this->handle($request, $author)); - } -} diff --git a/tests/__snapshots__/files/CommandMakeCommandTest__it_generates_console_command_for_the_module__1.php b/tests/__snapshots__/files/CommandMakeCommandTest__it_generates_console_command_for_the_module__1.php deleted file mode 100644 index 0f87a39..0000000 --- a/tests/__snapshots__/files/CommandMakeCommandTest__it_generates_console_command_for_the_module__1.php +++ /dev/null @@ -1,30 +0,0 @@ -|Author create($attributes = [], ?Author $parent = null) - * @method \Illuminate\Support\Collection createMany(iterable $records) - * @method Author createOne($attributes = []) - * @method \Illuminate\Support\Collection|Author make($attributes = [], ?Author $parent = null) - * @method Author makeOne($attributes = []) - */ -class SomeAuthorFactory extends Factory -{ - /** - * The name of the factory's corresponding model. - * - * @var class-string - */ - protected $model = Author::class; - - /** - * Define the model's default state. - */ - public function definition(): array - { - return [ - // TODO: add fields here - ]; - } -} diff --git a/tests/__snapshots__/files/JobMakeCommandTest__it_generates__plain__job_for_the_module__1.php b/tests/__snapshots__/files/JobMakeCommandTest__it_generates__plain__job_for_the_module__1.php deleted file mode 100644 index eb911c8..0000000 --- a/tests/__snapshots__/files/JobMakeCommandTest__it_generates__plain__job_for_the_module__1.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ - public function attachments(): array - { - return []; - } -} diff --git a/tests/__snapshots__/files/MiddlewareMakeCommandTest__it_generates_middleware_for_the_module__1.php b/tests/__snapshots__/files/MiddlewareMakeCommandTest__it_generates_middleware_for_the_module__1.php deleted file mode 100644 index d2f58c3..0000000 --- a/tests/__snapshots__/files/MiddlewareMakeCommandTest__it_generates_middleware_for_the_module__1.php +++ /dev/null @@ -1,20 +0,0 @@ -string('title'); - - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('articles', static function (Blueprint $table) { - $table->dropColumn('title'); - - }); - } -}; diff --git a/tests/__snapshots__/files/MigrationMakeCommandTest__it_automatically_detects_and_generates__create__migration_for_the_module__1.php b/tests/__snapshots__/files/MigrationMakeCommandTest__it_automatically_detects_and_generates__create__migration_for_the_module__1.php deleted file mode 100644 index a1fea11..0000000 --- a/tests/__snapshots__/files/MigrationMakeCommandTest__it_automatically_detects_and_generates__create__migration_for_the_module__1.php +++ /dev/null @@ -1,33 +0,0 @@ -id(); - $table->string('title'); - $table->text('excerpt'); - $table->text('content'); - $table->integer('user_id')->unsigned(); - $table->foreign('user_id')->references('id')->on('users'); - - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('articles'); - } -}; diff --git a/tests/__snapshots__/files/MigrationMakeCommandTest__it_automatically_detects_and_generates__delete__migration_for_the_module__1.php b/tests/__snapshots__/files/MigrationMakeCommandTest__it_automatically_detects_and_generates__delete__migration_for_the_module__1.php deleted file mode 100644 index 02dbdeb..0000000 --- a/tests/__snapshots__/files/MigrationMakeCommandTest__it_automatically_detects_and_generates__delete__migration_for_the_module__1.php +++ /dev/null @@ -1,30 +0,0 @@ -dropColumn('title'); - - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('articles', static function (Blueprint $table) { - $table->string('title'); - - }); - } -}; diff --git a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__add__migration_for_the_module__1.php b/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__add__migration_for_the_module__1.php deleted file mode 100644 index 34f727a..0000000 --- a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__add__migration_for_the_module__1.php +++ /dev/null @@ -1,36 +0,0 @@ -string('title'); - $table->text('excerpt'); - $table->integer('user_id')->unsigned(); - $table->foreign('user_id')->references('id')->on('users'); - - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('articles', static function (Blueprint $table) { - $table->dropColumn('title'); - $table->dropColumn('excerpt'); - $table->dropColumn('user_id'); - $table->dropForeign(['user_id']); - - }); - } -}; diff --git a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__add__migration_for_the_module_without_fields__1.php b/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__add__migration_for_the_module_without_fields__1.php deleted file mode 100644 index 11a9d71..0000000 --- a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__add__migration_for_the_module_without_fields__1.php +++ /dev/null @@ -1,28 +0,0 @@ -id(); - $table->string('title'); - $table->text('excerpt'); - $table->text('content'); - $table->integer('user_id')->unsigned(); - $table->foreign('user_id')->references('id')->on('users'); - - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('articles'); - } -}; diff --git a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__create__migration_for_the_module_without_fields__1.php b/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__create__migration_for_the_module_without_fields__1.php deleted file mode 100644 index 13b5e38..0000000 --- a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__create__migration_for_the_module_without_fields__1.php +++ /dev/null @@ -1,28 +0,0 @@ -id(); - - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('articles'); - } -}; diff --git a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__delete__migration_for_the_module__1.php b/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__delete__migration_for_the_module__1.php deleted file mode 100644 index 72d9761..0000000 --- a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__delete__migration_for_the_module__1.php +++ /dev/null @@ -1,36 +0,0 @@ -dropColumn('title'); - $table->dropColumn('excerpt'); - $table->dropColumn('user_id'); - $table->dropForeign(['user_id']); - - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('articles', static function (Blueprint $table) { - $table->string('title'); - $table->text('excerpt'); - $table->integer('user_id')->unsigned(); - $table->foreign('user_id')->references('id')->on('users'); - - }); - } -}; diff --git a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__delete__migration_for_the_module_without_fields__1.php b/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__delete__migration_for_the_module_without_fields__1.php deleted file mode 100644 index 11a9d71..0000000 --- a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__delete__migration_for_the_module_without_fields__1.php +++ /dev/null @@ -1,28 +0,0 @@ -unsignedBigInteger('article_id')->index(); - $table->foreign('article_id')->references('id')->on('articles')->onDelete('cascade'); - $table->unsignedBigInteger('author_id')->index(); - $table->foreign('author_id')->references('id')->on('authors')->onDelete('cascade'); - $table->primary(['article_id', 'author_id']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('article_author'); - } -}; diff --git a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__plain__migration_for_the_module__1.php b/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__plain__migration_for_the_module__1.php deleted file mode 100644 index 88fa2f3..0000000 --- a/tests/__snapshots__/files/MigrationMakeCommandTest__it_generates__plain__migration_for_the_module__1.php +++ /dev/null @@ -1,24 +0,0 @@ -all(); - - if ($data) { - $articleComment->update($data); - } - - return $articleComment; - } - - public function asController(UpdateArticleCommentRequest $request, ArticleComment $articleComment): ArticleCommentResource - { - $articleComment = $this->handle($articleComment, $request->toDTO()); - - return new ArticleCommentResource($articleComment); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__11.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__11.php deleted file mode 100644 index 74f5661..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__11.php +++ /dev/null @@ -1,25 +0,0 @@ -build(); - } - - public function asController(ViewArticleCommentRequest $request, ArticleComment $articleComment): ArticleCommentResource - { - return new ArticleCommentResource($this->handle($request, $articleComment)); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__12.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__12.php deleted file mode 100644 index 3bbcd57..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__12.php +++ /dev/null @@ -1,13 +0,0 @@ -can('view-article-comment'); - } - - /** - * Determine whether the user can view the model. - */ - public function view(User $user, ArticleComment $articleComment): bool - { - return $user->can('view-article-comment'); - } - - /** - * Determine whether the user can create models. - */ - public function create(User $user): bool - { - return $user->can('create-article-comment'); - } - - /** - * Determine whether the user can update the model. - */ - public function update(User $user, ArticleComment $articleComment): bool - { - return $user->can('update-article-comment'); - } - - /** - * Determine whether the user can delete the model. - */ - public function delete(User $user, ArticleComment $articleComment): bool - { - return $user->can('delete-article-comment'); - } - - /** - * Determine whether the user can restore the model. - */ - public function restore(User $user, ArticleComment $articleComment): bool - { - return $user->can('delete-article-comment'); - } - - /** - * Determine whether the user can permanently delete the model. - */ - public function forceDelete(User $user, ArticleComment $articleComment): bool - { - return $user->can('force-delete-article-comment'); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__16.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__16.php deleted file mode 100644 index 1696363..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__16.php +++ /dev/null @@ -1,102 +0,0 @@ -loadConfigurations(); - } - - /** - * Bootstrap services. - */ - public function boot(): void - { - $this->loadMigrations(); - // $this->loadCommands(); - // $this->loadTranslations(); - // $this->loadViews(); - } - - /** - * Register configuration files. - */ - public function loadConfigurations(): void - { - $sourcePath = __DIR__.'/../../config/article-comment.php'; - $configsPath = $this->app->configPath('article-comment.php'); - - $this->mergeConfigFrom($sourcePath, 'article-comment'); - - $this->publishes([ - $sourcePath => $configsPath - ], 'article-comment-config'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = __DIR__.'/../../database/migrations'; - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath - ], 'article-comment-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\ArticleComment\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = __DIR__.'/../../lang'; - $langPath = $this->app->langPath('modules/article-comment'); - - $this->loadTranslationsFrom($sourcePath, 'article-comment'); - - $this->publishes([ - $sourcePath => $langPath, - ], 'article-comment-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = __DIR__.'/../../resources/views'; - $viewsPath = $this->app->resourcePath('views/modules/article-comment'); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths('article-comment'), [$sourcePath]), - 'article-comment' - ); - - $this->publishes([ - $sourcePath => $viewsPath - ], 'article-comment-views'); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__17.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__17.php deleted file mode 100644 index ba84e41..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__17.php +++ /dev/null @@ -1,57 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\ArticleComment\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/../UI/WEB/routes'); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\ArticleComment\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/../UI/API/routes'); - }); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__18.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__18.php deleted file mode 100644 index 89a3dec..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__18.php +++ /dev/null @@ -1,51 +0,0 @@ - - */ - protected function allowedAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedFields(): array - { - return [ - // TODO: add fields here - ]; - } - - /** - * @return array - */ - protected function allowedIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultIncludes(): array - { - return []; - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__19.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__19.php deleted file mode 100644 index 804bc9e..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__19.php +++ /dev/null @@ -1,77 +0,0 @@ - - */ - protected function allowedAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedFields(): array - { - return [ - // TODO: add fields here - ]; - } - - /** - * @return array - */ - protected function allowedFilters(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedSorts(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultSorts(): array - { - return []; - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__20.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__20.php deleted file mode 100644 index 0c198a3..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__20.php +++ /dev/null @@ -1,28 +0,0 @@ -validated()); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__21.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__21.php deleted file mode 100644 index 2710975..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__21.php +++ /dev/null @@ -1,20 +0,0 @@ -route('articleComment'); - return $articleComment && Gate::check('delete', $articleComment); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__22.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__22.php deleted file mode 100644 index 16b668a..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__22.php +++ /dev/null @@ -1,20 +0,0 @@ -route('articleComment'); - return $articleComment && Gate::check('update', $articleComment); - } - - public function toDTO(): UpdateArticleCommentDTO - { - return UpdateArticleCommentDTO::from($this->validated()); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__24.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__24.php deleted file mode 100644 index 814263f..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__24.php +++ /dev/null @@ -1,20 +0,0 @@ -route('articleComment'); - return $articleComment && Gate::check('view', $articleComment); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__25.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__25.php deleted file mode 100644 index 368a4fd..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__25.php +++ /dev/null @@ -1,17 +0,0 @@ -name('api.article_comments.create'); diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__27.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__27.php deleted file mode 100644 index 3098b2d..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__27.php +++ /dev/null @@ -1,7 +0,0 @@ -name('api.article_comments.delete'); diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__28.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__28.php deleted file mode 100644 index 2fc2cd8..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__28.php +++ /dev/null @@ -1,7 +0,0 @@ -name('api.article_comments.list'); diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__29.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__29.php deleted file mode 100644 index 872915f..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__29.php +++ /dev/null @@ -1,7 +0,0 @@ -name('api.article_comments.update'); diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__3.json b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__3.json deleted file mode 100644 index e7f3966..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__3.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "demo/article-comment", - "description": "article-comment module", - "version": "1.0.0", - "authors": [ - { - "name": "Example", - "email": "example@example.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\ArticleComment\\": "src/", - "Modules\\ArticleComment\\Database\\Factories\\": "database/factories/", - "Modules\\ArticleComment\\Database\\Seeders\\": "database/seeders/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\ArticleComment\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\ArticleComment\\Providers\\ArticleCommentServiceProvider", - "Modules\\ArticleComment\\Providers\\RouteServiceProvider" - ], - "aliases": [] - } - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__30.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__30.php deleted file mode 100644 index b6557d5..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__30.php +++ /dev/null @@ -1,7 +0,0 @@ -name('api.article_comments.view'); diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__31.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__31.php deleted file mode 100644 index d7c37e4..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__31.php +++ /dev/null @@ -1,79 +0,0 @@ - 'create-article-comment', - 'roles' => '', - ]; - - public function test_create_article_comment(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->postJson(route('api.article_comments.create'), $data) - ->assertCreated() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->has('id') - ->whereAll($data) - ->etc() - ) - ); - - $this->assertDatabaseHas(ArticleComment::class, $data); - } - - public function test_create_article_comment_with_invalid_data(): void - { - $this->actingAsTestUser(); - - $this->postJson(route('api.article_comments.create'), []) - ->assertUnprocessable() - ->assertJsonValidationErrors([ - // TODO: add expected validation errors here - ]); - } - - public function test_create_article_comment_unauthenticated(): void - { - $data = $this->getTestData(); - - $this->postJson(route('api.article_comments.create'), $data) - ->assertUnauthorized(); - } - - public function test_create_article_comment_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $data = $this->getTestData(); - - $this->postJson(route('api.article_comments.create'), $data) - ->assertForbidden(); - } - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__32.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__32.php deleted file mode 100644 index 0bf472e..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__32.php +++ /dev/null @@ -1,61 +0,0 @@ - 'delete-article-comment', - 'roles' => '', - ]; - - public function test_delete_article_comment(): void - { - $this->actingAsTestUser(); - - $articleComment = ArticleComment::factory()->create(); - - $this->deleteJson(route('api.article_comments.delete', ['articleComment' => $articleComment->getKey()])) - ->assertNoContent(); - - $this->assertNull(ArticleComment::find($articleComment->getKey())); - } - - public function test_delete_article_comment_unauthenticated(): void - { - $articleComment = ArticleComment::factory()->create(); - - $this->deleteJson(route('api.article_comments.delete', ['articleComment' => $articleComment->getKey()])) - ->assertUnauthorized(); - } - - public function test_delete_article_comment_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $articleComment = ArticleComment::factory()->create(); - - $this->deleteJson(route('api.article_comments.delete', ['articleComment' => $articleComment->getKey()])) - ->assertForbidden(); - } - - public function test_delete_not_existing_article_comment(): void - { - $this->actingAsTestUser(); - - $this->deleteJson(route('api.article_comments.delete', ['articleComment' => 7777])) - ->assertNotFound(); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__33.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__33.php deleted file mode 100644 index 467f0e4..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__33.php +++ /dev/null @@ -1,57 +0,0 @@ - 'view-article-comment', - 'roles' => '', - ]; - - public function test_list_article_comments(): void - { - $this->actingAsTestUser(); - - ArticleComment::factory()->count(3)->create(); - - $this->getJson(route('api.article_comments.list')) - ->assertOk() - ->assertJsonStructure([ - 'links', - 'meta', - 'data' - ]) - ->assertJsonCount(3, 'data'); - } - - public function test_list_article_comments_unauthenticated(): void - { - ArticleComment::factory()->count(3)->create(); - - $this->getJson(route('api.article_comments.list')) - ->assertUnauthorized(); - } - - public function test_list_article_comments_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - ArticleComment::factory()->count(3)->create(); - - $this->getJson(route('api.article_comments.list')) - ->assertForbidden(); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__34.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__34.php deleted file mode 100644 index cabac0f..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__34.php +++ /dev/null @@ -1,99 +0,0 @@ - 'update-article-comment', - 'roles' => '', - ]; - - public function test_update_article_comment(): void - { - $this->actingAsTestUser(); - - $articleComment = ArticleComment::factory()->create(); - - $data = $this->getTestData(); - $expectedData = array_merge($data, [ - 'id' => $articleComment->getKey(), - ]); - - $this->patchJson(route('api.article_comments.update', ['articleComment' => $articleComment->getKey()]), $data) - ->assertOk() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->whereAll($expectedData) - ->etc() - ) - ); - - $this->assertDatabaseHas(ArticleComment::class, $expectedData); - } - - public function test_update_article_comment_with_invalid_data(): void - { - $this->actingAsTestUser(); - - $articleComment = ArticleComment::factory()->create(); - - $this->patchJson(route('api.article_comments.update', ['articleComment' => $articleComment->getKey()]), []) - ->assertUnprocessable() - ->assertJsonValidationErrors([ - // TODO: add expected validation errors here - ]); - } - - public function test_update_article_comment_unauthenticated(): void - { - $articleComment = ArticleComment::factory()->create(); - - $data = $this->getTestData(); - - $this->patchJson(route('api.article_comments.update', ['articleComment' => $articleComment->getKey()]), $data) - ->assertUnauthorized(); - } - - public function test_update_article_comment_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $articleComment = ArticleComment::factory()->create(); - - $data = $this->getTestData(); - - $this->patchJson(route('api.article_comments.update', ['articleComment' => $articleComment->getKey()]), $data) - ->assertForbidden(); - } - - public function test_update_non_existing_article_comment(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->patchJson(route('api.article_comments.update', ['articleComment' => 7777]), $data) - ->assertNotFound(); - } - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__35.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__35.php deleted file mode 100644 index 839d499..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__35.php +++ /dev/null @@ -1,67 +0,0 @@ - 'view-article-comment', - 'roles' => '', - ]; - - public function test_view_article_comment(): void - { - $this->actingAsTestUser(); - - $articleComment = ArticleComment::factory()->create(); - $expectedData = $articleComment->toArray(); - - $this->getJson(route('api.article_comments.view', ['articleComment' => $articleComment->getKey()])) - ->assertOk() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->whereAll($expectedData) - ->etc() - ) - ); - } - - public function test_view_article_comment_unauthenticated(): void - { - $articleComment = ArticleComment::factory()->create(); - - $this->getJson(route('api.article_comments.view', ['articleComment' => $articleComment->getKey()])) - ->assertUnauthorized(); - } - - public function test_view_article_comment_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $articleComment = ArticleComment::factory()->create(); - - $this->getJson(route('api.article_comments.view', ['articleComment' => $articleComment->getKey()])) - ->assertForbidden(); - } - - public function test_view_not_existing_article_comment(): void - { - $this->actingAsTestUser(); - - $this->getJson(route('api.article_comments.view', ['articleComment' => 7777])) - ->assertNotFound(); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__4.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__4.php deleted file mode 100644 index 28b67d9..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__4.php +++ /dev/null @@ -1,33 +0,0 @@ -|ArticleComment create($attributes = [], ?ArticleComment $parent = null) - * @method \Illuminate\Support\Collection createMany(iterable $records) - * @method ArticleComment createOne($attributes = []) - * @method \Illuminate\Support\Collection|ArticleComment make($attributes = [], ?ArticleComment $parent = null) - * @method ArticleComment makeOne($attributes = []) - */ -class ArticleCommentFactory extends Factory -{ - /** - * The name of the factory's corresponding model. - * - * @var class-string - */ - protected $model = ArticleComment::class; - - /** - * Define the model's default state. - */ - public function definition(): array - { - return [ - // TODO: add fields here - ]; - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__5.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__5.php deleted file mode 100644 index 6f9bd1b..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__5.php +++ /dev/null @@ -1,28 +0,0 @@ -id(); - - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('article_comments'); - } -}; diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__6.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__6.php deleted file mode 100644 index 7e1e80a..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__6.php +++ /dev/null @@ -1,45 +0,0 @@ -handle(new CreatePermissionDTO( - name: 'view-article-comment', - display_name: 'View any "article-comments"', - group: 'article-comments' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'create-article-comment', - display_name: 'Create "article-comments"', - group: 'article-comments' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'update-article-comment', - display_name: 'Update any "article-comments"', - group: 'article-comments' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'delete-article-comment', - display_name: 'Delete any "article-comments"', - group: 'article-comments' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'force-delete-article-comment', - display_name: 'Force delete any "article-comments"', - group: 'article-comments' - )); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__7.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__7.php deleted file mode 100644 index 27192d2..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__7.php +++ /dev/null @@ -1,27 +0,0 @@ -all()); - } - - public function asController(CreateArticleCommentRequest $request): JsonResponse - { - $articleComment = $this->handle($request->toDTO()); - - return (new ArticleCommentResource($articleComment))->created(); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__8.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__8.php deleted file mode 100644 index 710f7e1..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__8.php +++ /dev/null @@ -1,25 +0,0 @@ -delete(); - } - - public function asController(DeleteArticleCommentRequest $request, ArticleComment $articleComment): Response - { - $this->handle($articleComment); - - return $this->noContent(); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__9.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__9.php deleted file mode 100644 index 865dd73..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__api__module__9.php +++ /dev/null @@ -1,28 +0,0 @@ -build() - ->paginate(); - } - - public function asController(ListArticleCommentsRequest $request): AnonymousResourceCollection - { - return ArticleCommentResource::collection($this->handle($request)); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__1.json b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__1.json deleted file mode 100644 index 6af9462..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__1.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "name": "laraneat/laraneat", - "type": "project", - "description": "The Laraneat framework.", - "keywords": [ - "laraneat", - "laravel", - "apiato", - "php", - "framework" - ], - "license": "MIT", - "require": { - "php": "^8.1", - "demo/article-comment": "*", - "laravel/framework": "^10.0" - }, - "require-dev": { - "phpunit/phpunit": "^10.5.9" - }, - "autoload": { - "classmap": [ - "database" - ], - "psr-4": { - "App\\": "app/", - "Modules\\": "modules/" - } - }, - "extra": { - "laravel": { - "dont-discover": [] - } - }, - "config": { - "optimize-autoloader": true, - "preferred-install": "dist", - "sort-packages": true, - "allow-plugins": { - "pestphp/pest-plugin": true, - "php-http/discovery": true - } - }, - "minimum-stability": "stable", - "prefer-stable": true, - "repositories": [ - { - "type": "path", - "url": "modules/*", - "options": { - "symlink": true - } - }, - { - "type": "composer", - "url": "https://repo.packagist.org", - "exclude": [ - "demo/*" - ] - }, - { - "packagist.org": false - } - ] -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__10.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__10.php deleted file mode 100644 index ba84e41..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__10.php +++ /dev/null @@ -1,57 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\ArticleComment\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/../UI/WEB/routes'); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\ArticleComment\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/../UI/API/routes'); - }); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__3.json b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__3.json deleted file mode 100644 index e7f3966..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__3.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "demo/article-comment", - "description": "article-comment module", - "version": "1.0.0", - "authors": [ - { - "name": "Example", - "email": "example@example.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\ArticleComment\\": "src/", - "Modules\\ArticleComment\\Database\\Factories\\": "database/factories/", - "Modules\\ArticleComment\\Database\\Seeders\\": "database/seeders/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\ArticleComment\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\ArticleComment\\Providers\\ArticleCommentServiceProvider", - "Modules\\ArticleComment\\Providers\\RouteServiceProvider" - ], - "aliases": [] - } - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__4.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__4.php deleted file mode 100644 index 28b67d9..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__4.php +++ /dev/null @@ -1,33 +0,0 @@ -|ArticleComment create($attributes = [], ?ArticleComment $parent = null) - * @method \Illuminate\Support\Collection createMany(iterable $records) - * @method ArticleComment createOne($attributes = []) - * @method \Illuminate\Support\Collection|ArticleComment make($attributes = [], ?ArticleComment $parent = null) - * @method ArticleComment makeOne($attributes = []) - */ -class ArticleCommentFactory extends Factory -{ - /** - * The name of the factory's corresponding model. - * - * @var class-string - */ - protected $model = ArticleComment::class; - - /** - * Define the model's default state. - */ - public function definition(): array - { - return [ - // TODO: add fields here - ]; - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__5.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__5.php deleted file mode 100644 index 6f9bd1b..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__5.php +++ /dev/null @@ -1,28 +0,0 @@ -id(); - - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('article_comments'); - } -}; diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__6.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__6.php deleted file mode 100644 index 7e1e80a..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__6.php +++ /dev/null @@ -1,45 +0,0 @@ -handle(new CreatePermissionDTO( - name: 'view-article-comment', - display_name: 'View any "article-comments"', - group: 'article-comments' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'create-article-comment', - display_name: 'Create "article-comments"', - group: 'article-comments' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'update-article-comment', - display_name: 'Update any "article-comments"', - group: 'article-comments' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'delete-article-comment', - display_name: 'Delete any "article-comments"', - group: 'article-comments' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'force-delete-article-comment', - display_name: 'Force delete any "article-comments"', - group: 'article-comments' - )); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__7.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__7.php deleted file mode 100644 index 1b97872..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__7.php +++ /dev/null @@ -1,33 +0,0 @@ -can('view-article-comment'); - } - - /** - * Determine whether the user can view the model. - */ - public function view(User $user, ArticleComment $articleComment): bool - { - return $user->can('view-article-comment'); - } - - /** - * Determine whether the user can create models. - */ - public function create(User $user): bool - { - return $user->can('create-article-comment'); - } - - /** - * Determine whether the user can update the model. - */ - public function update(User $user, ArticleComment $articleComment): bool - { - return $user->can('update-article-comment'); - } - - /** - * Determine whether the user can delete the model. - */ - public function delete(User $user, ArticleComment $articleComment): bool - { - return $user->can('delete-article-comment'); - } - - /** - * Determine whether the user can restore the model. - */ - public function restore(User $user, ArticleComment $articleComment): bool - { - return $user->can('delete-article-comment'); - } - - /** - * Determine whether the user can permanently delete the model. - */ - public function forceDelete(User $user, ArticleComment $articleComment): bool - { - return $user->can('force-delete-article-comment'); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__9.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__9.php deleted file mode 100644 index 1696363..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__base__module__9.php +++ /dev/null @@ -1,102 +0,0 @@ -loadConfigurations(); - } - - /** - * Bootstrap services. - */ - public function boot(): void - { - $this->loadMigrations(); - // $this->loadCommands(); - // $this->loadTranslations(); - // $this->loadViews(); - } - - /** - * Register configuration files. - */ - public function loadConfigurations(): void - { - $sourcePath = __DIR__.'/../../config/article-comment.php'; - $configsPath = $this->app->configPath('article-comment.php'); - - $this->mergeConfigFrom($sourcePath, 'article-comment'); - - $this->publishes([ - $sourcePath => $configsPath - ], 'article-comment-config'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = __DIR__.'/../../database/migrations'; - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath - ], 'article-comment-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\ArticleComment\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = __DIR__.'/../../lang'; - $langPath = $this->app->langPath('modules/article-comment'); - - $this->loadTranslationsFrom($sourcePath, 'article-comment'); - - $this->publishes([ - $sourcePath => $langPath, - ], 'article-comment-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = __DIR__.'/../../resources/views'; - $viewsPath = $this->app->resourcePath('views/modules/article-comment'); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths('article-comment'), [$sourcePath]), - 'article-comment' - ); - - $this->publishes([ - $sourcePath => $viewsPath - ], 'article-comment-views'); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__1.json b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__1.json deleted file mode 100644 index 6af9462..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__1.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "name": "laraneat/laraneat", - "type": "project", - "description": "The Laraneat framework.", - "keywords": [ - "laraneat", - "laravel", - "apiato", - "php", - "framework" - ], - "license": "MIT", - "require": { - "php": "^8.1", - "demo/article-comment": "*", - "laravel/framework": "^10.0" - }, - "require-dev": { - "phpunit/phpunit": "^10.5.9" - }, - "autoload": { - "classmap": [ - "database" - ], - "psr-4": { - "App\\": "app/", - "Modules\\": "modules/" - } - }, - "extra": { - "laravel": { - "dont-discover": [] - } - }, - "config": { - "optimize-autoloader": true, - "preferred-install": "dist", - "sort-packages": true, - "allow-plugins": { - "pestphp/pest-plugin": true, - "php-http/discovery": true - } - }, - "minimum-stability": "stable", - "prefer-stable": true, - "repositories": [ - { - "type": "path", - "url": "modules/*", - "options": { - "symlink": true - } - }, - { - "type": "composer", - "url": "https://repo.packagist.org", - "exclude": [ - "demo/*" - ] - }, - { - "packagist.org": false - } - ] -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__3.json b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__3.json deleted file mode 100644 index e7f3966..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__3.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "demo/article-comment", - "description": "article-comment module", - "version": "1.0.0", - "authors": [ - { - "name": "Example", - "email": "example@example.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\ArticleComment\\": "src/", - "Modules\\ArticleComment\\Database\\Factories\\": "database/factories/", - "Modules\\ArticleComment\\Database\\Seeders\\": "database/seeders/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\ArticleComment\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\ArticleComment\\Providers\\ArticleCommentServiceProvider", - "Modules\\ArticleComment\\Providers\\RouteServiceProvider" - ], - "aliases": [] - } - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__4.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__4.php deleted file mode 100644 index 1696363..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__4.php +++ /dev/null @@ -1,102 +0,0 @@ -loadConfigurations(); - } - - /** - * Bootstrap services. - */ - public function boot(): void - { - $this->loadMigrations(); - // $this->loadCommands(); - // $this->loadTranslations(); - // $this->loadViews(); - } - - /** - * Register configuration files. - */ - public function loadConfigurations(): void - { - $sourcePath = __DIR__.'/../../config/article-comment.php'; - $configsPath = $this->app->configPath('article-comment.php'); - - $this->mergeConfigFrom($sourcePath, 'article-comment'); - - $this->publishes([ - $sourcePath => $configsPath - ], 'article-comment-config'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = __DIR__.'/../../database/migrations'; - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath - ], 'article-comment-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\ArticleComment\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = __DIR__.'/../../lang'; - $langPath = $this->app->langPath('modules/article-comment'); - - $this->loadTranslationsFrom($sourcePath, 'article-comment'); - - $this->publishes([ - $sourcePath => $langPath, - ], 'article-comment-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = __DIR__.'/../../resources/views'; - $viewsPath = $this->app->resourcePath('views/modules/article-comment'); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths('article-comment'), [$sourcePath]), - 'article-comment' - ); - - $this->publishes([ - $sourcePath => $viewsPath - ], 'article-comment-views'); - } -} diff --git a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__5.php b/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__5.php deleted file mode 100644 index ba84e41..0000000 --- a/tests/__snapshots__/files/ModuleMakeCommandTest__it_generates_a__plain__module__5.php +++ /dev/null @@ -1,57 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\ArticleComment\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/../UI/WEB/routes'); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\ArticleComment\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/../UI/API/routes'); - }); - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_add_aliases__1.json b/tests/__snapshots__/files/ModuleTest__it_can_add_aliases__1.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_add_aliases__1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_add_aliases__2.json b/tests/__snapshots__/files/ModuleTest__it_can_add_aliases__2.json deleted file mode 100644 index ebe9ac3..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_add_aliases__2.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade", - "foo": "Modules\\Author\\Services\\Foo", - "bar": "Modules\\Bar\\Services\\Bar" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_add_aliases_via_ModuleConfigWriter__1.json b/tests/__snapshots__/files/ModuleTest__it_can_add_aliases_via_ModuleConfigWriter__1.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_add_aliases_via_ModuleConfigWriter__1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_add_aliases_via_ModuleConfigWriter__2.json b/tests/__snapshots__/files/ModuleTest__it_can_add_aliases_via_ModuleConfigWriter__2.json deleted file mode 100644 index f9631aa..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_add_aliases_via_ModuleConfigWriter__2.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade", - "bar": "Modules\\Bar\\Services\\Bar" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_add_providers__1.json b/tests/__snapshots__/files/ModuleTest__it_can_add_providers__1.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_add_providers__1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_add_providers__2.json b/tests/__snapshots__/files/ModuleTest__it_can_add_providers__2.json deleted file mode 100644 index 4491948..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_add_providers__2.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider", - "Modules\\Author\\Providers\\FooServiceProvider", - "Modules\\Foo\\Providers\\BarServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_add_providers_via_ModuleConfigWriter__1.json b/tests/__snapshots__/files/ModuleTest__it_can_add_providers_via_ModuleConfigWriter__1.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_add_providers_via_ModuleConfigWriter__1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_add_providers_via_ModuleConfigWriter__2.json b/tests/__snapshots__/files/ModuleTest__it_can_add_providers_via_ModuleConfigWriter__2.json deleted file mode 100644 index af000ba..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_add_providers_via_ModuleConfigWriter__2.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider", - "Modules\\Foo\\Providers\\BarServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_set_aliases__1.json b/tests/__snapshots__/files/ModuleTest__it_can_set_aliases__1.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_set_aliases__1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_set_aliases__2.json b/tests/__snapshots__/files/ModuleTest__it_can_set_aliases__2.json deleted file mode 100644 index 8505b7d..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_set_aliases__2.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "foo": "Modules\\Author\\Services\\Foo", - "bar": "Modules\\Bar\\Services\\Bar" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_set_providers__1.json b/tests/__snapshots__/files/ModuleTest__it_can_set_providers__1.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_set_providers__1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_set_providers__2.json b/tests/__snapshots__/files/ModuleTest__it_can_set_providers__2.json deleted file mode 100644 index ab6c0e8..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_set_providers__2.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\FooServiceProvider", - "Modules\\Foo\\Providers\\BarServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_update_aliases_via_ModuleConfigWriter__1.json b/tests/__snapshots__/files/ModuleTest__it_can_update_aliases_via_ModuleConfigWriter__1.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_update_aliases_via_ModuleConfigWriter__1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_update_aliases_via_ModuleConfigWriter__2.json b/tests/__snapshots__/files/ModuleTest__it_can_update_aliases_via_ModuleConfigWriter__2.json deleted file mode 100644 index 8505b7d..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_update_aliases_via_ModuleConfigWriter__2.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "foo": "Modules\\Author\\Services\\Foo", - "bar": "Modules\\Bar\\Services\\Bar" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_update_providers_via_ModuleConfigWriter__1.json b/tests/__snapshots__/files/ModuleTest__it_can_update_providers_via_ModuleConfigWriter__1.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_update_providers_via_ModuleConfigWriter__1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModuleTest__it_can_update_providers_via_ModuleConfigWriter__2.json b/tests/__snapshots__/files/ModuleTest__it_can_update_providers_via_ModuleConfigWriter__2.json deleted file mode 100644 index ab6c0e8..0000000 --- a/tests/__snapshots__/files/ModuleTest__it_can_update_providers_via_ModuleConfigWriter__2.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\FooServiceProvider", - "Modules\\Foo\\Providers\\BarServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/__snapshots__/files/ModulesRepositoryTest__it_can_sync_modules_with_composer__1.json b/tests/__snapshots__/files/ModulesRepositoryTest__it_can_sync_modules_with_composer__1.json deleted file mode 100644 index 1977705..0000000 --- a/tests/__snapshots__/files/ModulesRepositoryTest__it_can_sync_modules_with_composer__1.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "laraneat/laraneat", - "type": "project", - "description": "The Laraneat framework.", - "keywords": [ - "laraneat", - "laravel", - "apiato", - "php", - "framework" - ], - "license": "MIT", - "require": { - "php": "^8.1", - "laravel/framework": "^10.0" - }, - "require-dev": { - "phpunit/phpunit": "^10.5.9" - }, - "autoload": { - "classmap": [ - "database" - ], - "psr-4": { - "App\\": "app/", - "Modules\\": "modules/" - } - }, - "extra": { - "laravel": { - "dont-discover": [] - } - }, - "config": { - "optimize-autoloader": true, - "preferred-install": "dist", - "sort-packages": true, - "allow-plugins": { - "pestphp/pest-plugin": true, - "php-http/discovery": true - } - }, - "minimum-stability": "stable", - "prefer-stable": true -} diff --git a/tests/__snapshots__/files/ModulesRepositoryTest__it_can_sync_modules_with_composer__2.json b/tests/__snapshots__/files/ModulesRepositoryTest__it_can_sync_modules_with_composer__2.json deleted file mode 100644 index 4c694c0..0000000 --- a/tests/__snapshots__/files/ModulesRepositoryTest__it_can_sync_modules_with_composer__2.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "name": "laraneat/laraneat", - "type": "project", - "description": "The Laraneat framework.", - "keywords": [ - "laraneat", - "laravel", - "apiato", - "php", - "framework" - ], - "license": "MIT", - "require": { - "php": "^8.1", - "laraneat/article": "*", - "laraneat/article-category": "*", - "laraneat/author": "*", - "laraneat/empty": "*", - "laraneat/location": "*", - "laravel/framework": "^10.0" - }, - "require-dev": { - "phpunit/phpunit": "^10.5.9" - }, - "autoload": { - "classmap": [ - "database" - ], - "psr-4": { - "App\\": "app/", - "Modules\\": "modules/" - } - }, - "extra": { - "laravel": { - "dont-discover": [] - } - }, - "config": { - "optimize-autoloader": true, - "preferred-install": "dist", - "sort-packages": true, - "allow-plugins": { - "pestphp/pest-plugin": true, - "php-http/discovery": true - } - }, - "minimum-stability": "stable", - "prefer-stable": true, - "repositories": [ - { - "type": "path", - "url": "modules/*", - "options": { - "symlink": true - } - }, - { - "type": "composer", - "url": "https://repo.packagist.org", - "exclude": [ - "laraneat/*" - ] - }, - { - "packagist.org": false - } - ] -} diff --git a/tests/__snapshots__/files/NotificationMakeCommandTest__it_generates__plain__notification_for_the_module__1.php b/tests/__snapshots__/files/NotificationMakeCommandTest__it_generates__plain__notification_for_the_module__1.php deleted file mode 100644 index 1b65cc0..0000000 --- a/tests/__snapshots__/files/NotificationMakeCommandTest__it_generates__plain__notification_for_the_module__1.php +++ /dev/null @@ -1,36 +0,0 @@ - - */ - public function via(object $notifiable): array - { - return ['mail', 'database']; - } - - /** - * Get the array representation of the notification. - */ - public function toArray(object $notifiable): array - { - return [ - // - ]; - } -} diff --git a/tests/__snapshots__/files/NotificationMakeCommandTest__it_generates__queued__notification_for_the_module__1.php b/tests/__snapshots__/files/NotificationMakeCommandTest__it_generates__queued__notification_for_the_module__1.php deleted file mode 100644 index 14891e9..0000000 --- a/tests/__snapshots__/files/NotificationMakeCommandTest__it_generates__queued__notification_for_the_module__1.php +++ /dev/null @@ -1,54 +0,0 @@ - - */ - public function via(object $notifiable): array - { - return ['mail']; - } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage - { - return (new MailMessage) - ->line('The introduction to the notification.') - ->action('Notification Action', url('/')) - ->line('Thank you for using our application!'); - } - - /** - * Get the array representation of the notification. - * - * @return array - */ - public function toArray(object $notifiable): array - { - return [ - // - ]; - } -} diff --git a/tests/__snapshots__/files/ObserverMakeCommandTest__it_generates_observer_for_the_module__1.php b/tests/__snapshots__/files/ObserverMakeCommandTest__it_generates_observer_for_the_module__1.php deleted file mode 100644 index 5fc6ec5..0000000 --- a/tests/__snapshots__/files/ObserverMakeCommandTest__it_generates_observer_for_the_module__1.php +++ /dev/null @@ -1,48 +0,0 @@ -can('view-author'); - } - - /** - * Determine whether the user can view the model. - */ - public function view(User $user, Author $author): bool - { - return $user->can('view-author'); - } - - /** - * Determine whether the user can create models. - */ - public function create(User $user): bool - { - return $user->can('create-author'); - } - - /** - * Determine whether the user can update the model. - */ - public function update(User $user, Author $author): bool - { - return $user->can('update-author'); - } - - /** - * Determine whether the user can delete the model. - */ - public function delete(User $user, Author $author): bool - { - return $user->can('delete-author'); - } - - /** - * Determine whether the user can restore the model. - */ - public function restore(User $user, Author $author): bool - { - return $user->can('delete-author'); - } - - /** - * Determine whether the user can permanently delete the model. - */ - public function forceDelete(User $user, Author $author): bool - { - return $user->can('force-delete-author'); - } -} diff --git a/tests/__snapshots__/files/PolicyMakeCommandTest__it_generates_plain_policy_for_the_module__1.php b/tests/__snapshots__/files/PolicyMakeCommandTest__it_generates_plain_policy_for_the_module__1.php deleted file mode 100644 index 00de928..0000000 --- a/tests/__snapshots__/files/PolicyMakeCommandTest__it_generates_plain_policy_for_the_module__1.php +++ /dev/null @@ -1,8 +0,0 @@ -> - */ - protected $listen = []; - - /** - * Register any events for your application. - */ - public function boot(): void - { - // - } -} diff --git a/tests/__snapshots__/files/ProviderMakeCommandTest__it_generates__module__provider_for_the_module__1.php b/tests/__snapshots__/files/ProviderMakeCommandTest__it_generates__module__provider_for_the_module__1.php deleted file mode 100644 index 184b779..0000000 --- a/tests/__snapshots__/files/ProviderMakeCommandTest__it_generates__module__provider_for_the_module__1.php +++ /dev/null @@ -1,102 +0,0 @@ -loadConfigurations(); - } - - /** - * Bootstrap services. - */ - public function boot(): void - { - $this->loadMigrations(); - // $this->loadCommands(); - // $this->loadTranslations(); - // $this->loadViews(); - } - - /** - * Register configuration files. - */ - public function loadConfigurations(): void - { - $sourcePath = __DIR__.'/../../config/author.php'; - $configsPath = $this->app->configPath('author.php'); - - $this->mergeConfigFrom($sourcePath, 'author'); - - $this->publishes([ - $sourcePath => $configsPath - ], 'author-config'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = __DIR__.'/../../database/migrations'; - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath - ], 'author-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\Author\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = __DIR__.'/../../lang'; - $langPath = $this->app->langPath('modules/author'); - - $this->loadTranslationsFrom($sourcePath, 'author'); - - $this->publishes([ - $sourcePath => $langPath, - ], 'author-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = __DIR__.'/../../resources/views'; - $viewsPath = $this->app->resourcePath('views/modules/author'); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths('author'), [$sourcePath]), - 'author' - ); - - $this->publishes([ - $sourcePath => $viewsPath - ], 'author-views'); - } -} diff --git a/tests/__snapshots__/files/ProviderMakeCommandTest__it_generates__plain__provider_for_the_module__1.php b/tests/__snapshots__/files/ProviderMakeCommandTest__it_generates__plain__provider_for_the_module__1.php deleted file mode 100644 index f5a2650..0000000 --- a/tests/__snapshots__/files/ProviderMakeCommandTest__it_generates__plain__provider_for_the_module__1.php +++ /dev/null @@ -1,24 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\Author\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/../UI/WEB/routes'); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\Author\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(__DIR__.'/../UI/API/routes'); - }); - } -} diff --git a/tests/__snapshots__/files/QueryWizardMakeCommandTest__it_generates__eloquent__query_wizard_for_the_module__1.php b/tests/__snapshots__/files/QueryWizardMakeCommandTest__it_generates__eloquent__query_wizard_for_the_module__1.php deleted file mode 100644 index 63553d5..0000000 --- a/tests/__snapshots__/files/QueryWizardMakeCommandTest__it_generates__eloquent__query_wizard_for_the_module__1.php +++ /dev/null @@ -1,77 +0,0 @@ - - */ - protected function allowedAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedFields(): array - { - return [ - // TODO: add fields here - ]; - } - - /** - * @return array - */ - protected function allowedFilters(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedSorts(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultSorts(): array - { - return []; - } -} diff --git a/tests/__snapshots__/files/QueryWizardMakeCommandTest__it_generates__model__query_wizard_for_the_module__1.php b/tests/__snapshots__/files/QueryWizardMakeCommandTest__it_generates__model__query_wizard_for_the_module__1.php deleted file mode 100644 index 3a876bd..0000000 --- a/tests/__snapshots__/files/QueryWizardMakeCommandTest__it_generates__model__query_wizard_for_the_module__1.php +++ /dev/null @@ -1,51 +0,0 @@ - - */ - protected function allowedAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultAppends(): array - { - return []; - } - - /** - * @return array - */ - protected function allowedFields(): array - { - return [ - // TODO: add fields here - ]; - } - - /** - * @return array - */ - protected function allowedIncludes(): array - { - return []; - } - - /** - * @return array - */ - protected function defaultIncludes(): array - { - return []; - } -} diff --git a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__create__api_request_for_the_module__1.php b/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__create__api_request_for_the_module__1.php deleted file mode 100644 index c17fe79..0000000 --- a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__create__api_request_for_the_module__1.php +++ /dev/null @@ -1,28 +0,0 @@ -validated()); - } -} diff --git a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__create__web_request_for_the_module__1.php b/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__create__web_request_for_the_module__1.php deleted file mode 100644 index 80d4521..0000000 --- a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__create__web_request_for_the_module__1.php +++ /dev/null @@ -1,28 +0,0 @@ -validated()); - } -} diff --git a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__delete__api_request_for_the_module__1.php b/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__delete__api_request_for_the_module__1.php deleted file mode 100644 index d382341..0000000 --- a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__delete__api_request_for_the_module__1.php +++ /dev/null @@ -1,20 +0,0 @@ -route('author'); - return $author && Gate::check('delete', $author); - } -} diff --git a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__delete__web_request_for_the_module__1.php b/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__delete__web_request_for_the_module__1.php deleted file mode 100644 index 057de9f..0000000 --- a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__delete__web_request_for_the_module__1.php +++ /dev/null @@ -1,20 +0,0 @@ -route('author'); - return $author && Gate::check('delete', $author); - } -} diff --git a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__list__api_request_for_the_module__1.php b/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__list__api_request_for_the_module__1.php deleted file mode 100644 index db74862..0000000 --- a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__list__api_request_for_the_module__1.php +++ /dev/null @@ -1,20 +0,0 @@ -route('author'); - return $author && Gate::check('update', $author); - } - - public function toDTO(): UpdateAuthorDTO - { - return UpdateAuthorDTO::from($this->validated()); - } -} diff --git a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__update__web_request_for_the_module__1.php b/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__update__web_request_for_the_module__1.php deleted file mode 100644 index 4e6f3b3..0000000 --- a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__update__web_request_for_the_module__1.php +++ /dev/null @@ -1,28 +0,0 @@ -route('author'); - return $author && Gate::check('update', $author); - } - - public function toDTO(): UpdateAuthorDTO - { - return UpdateAuthorDTO::from($this->validated()); - } -} diff --git a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__view__api_request_for_the_module__1.php b/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__view__api_request_for_the_module__1.php deleted file mode 100644 index 3131436..0000000 --- a/tests/__snapshots__/files/RequestMakeCommandTest__it_generates__view__api_request_for_the_module__1.php +++ /dev/null @@ -1,20 +0,0 @@ -route('author'); - return $author && Gate::check('view', $author); - } -} diff --git a/tests/__snapshots__/files/ResourceMakeCommandTest__it_generates__collection__resource_for_the_module__1.php b/tests/__snapshots__/files/ResourceMakeCommandTest__it_generates__collection__resource_for_the_module__1.php deleted file mode 100644 index 66593f2..0000000 --- a/tests/__snapshots__/files/ResourceMakeCommandTest__it_generates__collection__resource_for_the_module__1.php +++ /dev/null @@ -1,17 +0,0 @@ -name('api.authors.delete'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__delete__web_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__delete__web_route_for_the_module__1.php deleted file mode 100644 index 951f87e..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__delete__web_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('web.authors.delete'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__get__api_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__get__api_route_for_the_module__1.php deleted file mode 100644 index e38cc25..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__get__api_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('web.authors.list'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__get__web_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__get__web_route_for_the_module__1.php deleted file mode 100644 index e38cc25..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__get__web_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('web.authors.list'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__options__api_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__options__api_route_for_the_module__1.php deleted file mode 100644 index 5369614..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__options__api_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('api.authors.options'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__options__web_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__options__web_route_for_the_module__1.php deleted file mode 100644 index 5dd3e59..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__options__web_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('web.authors.options'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__patch__api_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__patch__api_route_for_the_module__1.php deleted file mode 100644 index 640858f..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__patch__api_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('api.authors.update'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__patch__web_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__patch__web_route_for_the_module__1.php deleted file mode 100644 index fda74ad..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__patch__web_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('web.authors.update'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__post__api_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__post__api_route_for_the_module__1.php deleted file mode 100644 index 57b2069..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__post__api_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('api.authors.create'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__post__web_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__post__web_route_for_the_module__1.php deleted file mode 100644 index e031af6..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__post__web_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('web.authors.create'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__put__api_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__put__api_route_for_the_module__1.php deleted file mode 100644 index dcd1387..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__put__api_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('api.authors.update'); diff --git a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__put__web_route_for_the_module__1.php b/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__put__web_route_for_the_module__1.php deleted file mode 100644 index 34f88ba..0000000 --- a/tests/__snapshots__/files/RouteMakeCommandTest__it_generates__put__web_route_for_the_module__1.php +++ /dev/null @@ -1,7 +0,0 @@ -name('web.authors.update'); diff --git a/tests/__snapshots__/files/RuleMakeCommandTest__it_generates_rule_for_the_module__1.php b/tests/__snapshots__/files/RuleMakeCommandTest__it_generates_rule_for_the_module__1.php deleted file mode 100644 index 069e26b..0000000 --- a/tests/__snapshots__/files/RuleMakeCommandTest__it_generates_rule_for_the_module__1.php +++ /dev/null @@ -1,19 +0,0 @@ -handle(new CreatePermissionDTO( - name: 'view-author', - display_name: 'View any "authors"', - group: 'authors' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'create-author', - display_name: 'Create "authors"', - group: 'authors' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'update-author', - display_name: 'Update any "authors"', - group: 'authors' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'delete-author', - display_name: 'Delete any "authors"', - group: 'authors' - )); - - $createPermissionAction->handle(new CreatePermissionDTO( - name: 'force-delete-author', - display_name: 'Force delete any "authors"', - group: 'authors' - )); - } -} diff --git a/tests/__snapshots__/files/SeederMakeCommandTest__it_generates__plain__seeder_for_the_module__1.php b/tests/__snapshots__/files/SeederMakeCommandTest__it_generates__plain__seeder_for_the_module__1.php deleted file mode 100644 index 835b293..0000000 --- a/tests/__snapshots__/files/SeederMakeCommandTest__it_generates__plain__seeder_for_the_module__1.php +++ /dev/null @@ -1,13 +0,0 @@ - 'create-author', - 'roles' => '', - ]; - - public function test_create_author(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->postJson(route('api.authors.create'), $data) - ->assertCreated() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->has('id') - ->whereAll($data) - ->etc() - ) - ); - - $this->assertDatabaseHas(Author::class, $data); - } - - public function test_create_author_with_invalid_data(): void - { - $this->actingAsTestUser(); - - $this->postJson(route('api.authors.create'), []) - ->assertUnprocessable() - ->assertJsonValidationErrors([ - // TODO: add expected validation errors here - ]); - } - - public function test_create_author_unauthenticated(): void - { - $data = $this->getTestData(); - - $this->postJson(route('api.authors.create'), $data) - ->assertUnauthorized(); - } - - public function test_create_author_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $data = $this->getTestData(); - - $this->postJson(route('api.authors.create'), $data) - ->assertForbidden(); - } - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } -} diff --git a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_create__web__test_for_the_module__1.php b/tests/__snapshots__/files/TestMakeCommandTest__it_generates_create__web__test_for_the_module__1.php deleted file mode 100644 index 5b03452..0000000 --- a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_create__web__test_for_the_module__1.php +++ /dev/null @@ -1,49 +0,0 @@ - 'create-author', - 'roles' => '', - ]; - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } - - public function test_create_author(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->post(route('web.authors.create'), $data) - ->assertCreated(); - - $this->assertDatabaseHas(Author::class, $data); - } - - public function test_create_author_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $data = $this->getTestData(); - - $this->post(route('web.authors.create'), $data) - ->assertForbidden(); - } -} diff --git a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_delete__api__test_for_the_module__1.php b/tests/__snapshots__/files/TestMakeCommandTest__it_generates_delete__api__test_for_the_module__1.php deleted file mode 100644 index 1b8975a..0000000 --- a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_delete__api__test_for_the_module__1.php +++ /dev/null @@ -1,61 +0,0 @@ - 'delete-author', - 'roles' => '', - ]; - - public function test_delete_author(): void - { - $this->actingAsTestUser(); - - $author = Author::factory()->create(); - - $this->deleteJson(route('api.authors.delete', ['author' => $author->getKey()])) - ->assertNoContent(); - - $this->assertNull(Author::find($author->getKey())); - } - - public function test_delete_author_unauthenticated(): void - { - $author = Author::factory()->create(); - - $this->deleteJson(route('api.authors.delete', ['author' => $author->getKey()])) - ->assertUnauthorized(); - } - - public function test_delete_author_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $author = Author::factory()->create(); - - $this->deleteJson(route('api.authors.delete', ['author' => $author->getKey()])) - ->assertForbidden(); - } - - public function test_delete_not_existing_author(): void - { - $this->actingAsTestUser(); - - $this->deleteJson(route('api.authors.delete', ['author' => 7777])) - ->assertNotFound(); - } -} diff --git a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_delete__web__test_for_the_module__1.php b/tests/__snapshots__/files/TestMakeCommandTest__it_generates_delete__web__test_for_the_module__1.php deleted file mode 100644 index 102d004..0000000 --- a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_delete__web__test_for_the_module__1.php +++ /dev/null @@ -1,50 +0,0 @@ - 'delete-author', - 'roles' => '', - ]; - - public function test_delete_author(): void - { - $this->actingAsTestUser(); - - $author = Author::factory()->create(); - - $this->delete(route('web.authors.delete', ['author' => $author->getKey()])) - ->assertNoContent(); - - $this->assertNull(Author::find($author->getKey())); - } - - public function test_delete_author_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $author = Author::factory()->create(); - - $this->delete(route('web.authors.delete', ['author' => $author->getKey()])) - ->assertForbidden(); - } - - public function test_delete_not_existing_author(): void - { - $this->actingAsTestUser(); - - $this->delete(route('web.authors.delete', ['author' => 7777])) - ->assertNotFound(); - } -} diff --git a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_list__api__test_for_the_module__1.php b/tests/__snapshots__/files/TestMakeCommandTest__it_generates_list__api__test_for_the_module__1.php deleted file mode 100644 index d15bf85..0000000 --- a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_list__api__test_for_the_module__1.php +++ /dev/null @@ -1,57 +0,0 @@ - 'view-author', - 'roles' => '', - ]; - - public function test_list_authors(): void - { - $this->actingAsTestUser(); - - Author::factory()->count(3)->create(); - - $this->getJson(route('api.authors.list')) - ->assertOk() - ->assertJsonStructure([ - 'links', - 'meta', - 'data' - ]) - ->assertJsonCount(3, 'data'); - } - - public function test_list_authors_unauthenticated(): void - { - Author::factory()->count(3)->create(); - - $this->getJson(route('api.authors.list')) - ->assertUnauthorized(); - } - - public function test_list_authors_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - Author::factory()->count(3)->create(); - - $this->getJson(route('api.authors.list')) - ->assertForbidden(); - } -} diff --git a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_plain__api__test_for_the_module__1.php b/tests/__snapshots__/files/TestMakeCommandTest__it_generates_plain__api__test_for_the_module__1.php deleted file mode 100644 index 63a8cdb..0000000 --- a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_plain__api__test_for_the_module__1.php +++ /dev/null @@ -1,27 +0,0 @@ - '', - 'roles' => '', - ]; - - public function test(): void - { - // - } -} diff --git a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_plain__cli__test_for_the_module__1.php b/tests/__snapshots__/files/TestMakeCommandTest__it_generates_plain__cli__test_for_the_module__1.php deleted file mode 100644 index 777d3fb..0000000 --- a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_plain__cli__test_for_the_module__1.php +++ /dev/null @@ -1,16 +0,0 @@ - 'update-author', - 'roles' => '', - ]; - - public function test_update_author(): void - { - $this->actingAsTestUser(); - - $author = Author::factory()->create(); - - $data = $this->getTestData(); - $expectedData = array_merge($data, [ - 'id' => $author->getKey(), - ]); - - $this->patchJson(route('api.authors.update', ['author' => $author->getKey()]), $data) - ->assertOk() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->whereAll($expectedData) - ->etc() - ) - ); - - $this->assertDatabaseHas(Author::class, $expectedData); - } - - public function test_update_author_with_invalid_data(): void - { - $this->actingAsTestUser(); - - $author = Author::factory()->create(); - - $this->patchJson(route('api.authors.update', ['author' => $author->getKey()]), []) - ->assertUnprocessable() - ->assertJsonValidationErrors([ - // TODO: add expected validation errors here - ]); - } - - public function test_update_author_unauthenticated(): void - { - $author = Author::factory()->create(); - - $data = $this->getTestData(); - - $this->patchJson(route('api.authors.update', ['author' => $author->getKey()]), $data) - ->assertUnauthorized(); - } - - public function test_update_author_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $author = Author::factory()->create(); - - $data = $this->getTestData(); - - $this->patchJson(route('api.authors.update', ['author' => $author->getKey()]), $data) - ->assertForbidden(); - } - - public function test_update_non_existing_author(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->patchJson(route('api.authors.update', ['author' => 7777]), $data) - ->assertNotFound(); - } - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } -} diff --git a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_update__web__test_for_the_module__1.php b/tests/__snapshots__/files/TestMakeCommandTest__it_generates_update__web__test_for_the_module__1.php deleted file mode 100644 index 309002a..0000000 --- a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_update__web__test_for_the_module__1.php +++ /dev/null @@ -1,66 +0,0 @@ - 'update-author', - 'roles' => '', - ]; - - protected function getTestData(array $mergeData = []): array - { - return array_merge([ - // TODO: add fields here - ], $mergeData); - } - - public function test_update_author(): void - { - $this->actingAsTestUser(); - - $author = Author::factory()->create(); - - $data = $this->getTestData(); - $expectedData = array_merge($data, [ - 'id' => $author->getKey(), - ]); - - $this->patch(route('web.authors.update', ['author' => $author->getKey()]), $data) - ->assertOk(); - - $this->assertDatabaseHas(Author::class, $expectedData); - } - - public function test_update_author_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $author = Author::factory()->create(); - - $data = $this->getTestData(); - - $this->patch(route('web.authors.update', ['author' => $author->getKey()]), $data) - ->assertForbidden(); - } - - public function test_update_non_existing_author(): void - { - $this->actingAsTestUser(); - - $data = $this->getTestData(); - - $this->patch(route('web.authors.update', ['author' => 7777]), $data) - ->assertNotFound(); - } -} diff --git a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_view__api__test_for_the_module__1.php b/tests/__snapshots__/files/TestMakeCommandTest__it_generates_view__api__test_for_the_module__1.php deleted file mode 100644 index ec20f2d..0000000 --- a/tests/__snapshots__/files/TestMakeCommandTest__it_generates_view__api__test_for_the_module__1.php +++ /dev/null @@ -1,67 +0,0 @@ - 'view-author', - 'roles' => '', - ]; - - public function test_view_author(): void - { - $this->actingAsTestUser(); - - $author = Author::factory()->create(); - $expectedData = $author->toArray(); - - $this->getJson(route('api.authors.view', ['author' => $author->getKey()])) - ->assertOk() - ->assertJson(fn (AssertableJson $json) => - $json->has('data', fn (AssertableJson $json) => - $json->whereAll($expectedData) - ->etc() - ) - ); - } - - public function test_view_author_unauthenticated(): void - { - $author = Author::factory()->create(); - - $this->getJson(route('api.authors.view', ['author' => $author->getKey()])) - ->assertUnauthorized(); - } - - public function test_view_author_without_access(): void - { - $this->actingAsTestUserWithoutAccess(); - - $author = Author::factory()->create(); - - $this->getJson(route('api.authors.view', ['author' => $author->getKey()])) - ->assertForbidden(); - } - - public function test_view_not_existing_author(): void - { - $this->actingAsTestUser(); - - $this->getJson(route('api.authors.view', ['author' => 7777])) - ->assertNotFound(); - } -} diff --git a/tests/fixtures/laravel/.gitignore b/tests/fixtures/laravel/.gitignore deleted file mode 100644 index 8d29ab4..0000000 --- a/tests/fixtures/laravel/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -modules -vendor diff --git a/tests/fixtures/laravel/app/.gitkeep b/tests/fixtures/laravel/app/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/fixtures/laravel/bootstrap/app.php b/tests/fixtures/laravel/bootstrap/app.php deleted file mode 100644 index 151dd2e..0000000 --- a/tests/fixtures/laravel/bootstrap/app.php +++ /dev/null @@ -1,22 +0,0 @@ - ['APP_KEY="'.$APP_KEY.'"', 'DB_CONNECTION="'.$DB_CONNECTION.'"'], 'providers' => []]; - -$app = (new Commander($config, getcwd()))->laravel(); - -unset($APP_KEY, $DB_CONNECTION, $config); - -$router = $app->make('router'); - -collect(glob(__DIR__.'/../routes/testbench-*.php')) - ->each(function ($routeFile) use ($app, $router) { - require $routeFile; - }); - - -return $app; diff --git a/tests/fixtures/laravel/bootstrap/cache/.gitignore b/tests/fixtures/laravel/bootstrap/cache/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/tests/fixtures/laravel/bootstrap/cache/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/fixtures/laravel/composer.json b/tests/fixtures/laravel/composer.json deleted file mode 100644 index 1977705..0000000 --- a/tests/fixtures/laravel/composer.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "laraneat/laraneat", - "type": "project", - "description": "The Laraneat framework.", - "keywords": [ - "laraneat", - "laravel", - "apiato", - "php", - "framework" - ], - "license": "MIT", - "require": { - "php": "^8.1", - "laravel/framework": "^10.0" - }, - "require-dev": { - "phpunit/phpunit": "^10.5.9" - }, - "autoload": { - "classmap": [ - "database" - ], - "psr-4": { - "App\\": "app/", - "Modules\\": "modules/" - } - }, - "extra": { - "laravel": { - "dont-discover": [] - } - }, - "config": { - "optimize-autoloader": true, - "preferred-install": "dist", - "sort-packages": true, - "allow-plugins": { - "pestphp/pest-plugin": true, - "php-http/discovery": true - } - }, - "minimum-stability": "stable", - "prefer-stable": true -} diff --git a/tests/fixtures/laravel/config/app.php b/tests/fixtures/laravel/config/app.php deleted file mode 100644 index 1bd9f73..0000000 --- a/tests/fixtures/laravel/config/app.php +++ /dev/null @@ -1,234 +0,0 @@ - env('APP_NAME', 'Laravel'), - - /* - |-------------------------------------------------------------------------- - | Application Environment - |-------------------------------------------------------------------------- - | - | This value determines the "environment" your application is currently - | running in. This may determine how you prefer to configure various - | services the application utilizes. Set this in your ".env" file. - | - */ - - 'env' => env('APP_ENV', 'testing'), - - /* - |-------------------------------------------------------------------------- - | Application Debug Mode - |-------------------------------------------------------------------------- - | - | When your application is in debug mode, detailed error messages with - | stack traces will be shown on every error that occurs within your - | application. If disabled, a simple generic error page is shown. - | - */ - - 'debug' => (bool) env('APP_DEBUG', false), - - /* - |-------------------------------------------------------------------------- - | Application URL - |-------------------------------------------------------------------------- - | - | This URL is used by the console to properly generate URLs when using - | the Artisan command line tool. You should set this to the root of - | your application so that it is used when running Artisan tasks. - | - */ - - 'url' => env('APP_URL', 'http://localhost'), - - 'asset_url' => env('ASSET_URL', null), - - /* - |-------------------------------------------------------------------------- - | Application Timezone - |-------------------------------------------------------------------------- - | - | Here you may specify the default timezone for your application, which - | will be used by the PHP date and date-time functions. We have gone - | ahead and set this to a sensible default for you out of the box. - | - */ - - 'timezone' => 'UTC', - - /* - |-------------------------------------------------------------------------- - | Application Locale Configuration - |-------------------------------------------------------------------------- - | - | The application locale determines the default locale that will be used - | by the translation service provider. You are free to set this value - | to any of the locales which will be supported by the application. - | - */ - - 'locale' => 'en', - - /* - |-------------------------------------------------------------------------- - | Application Fallback Locale - |-------------------------------------------------------------------------- - | - | The fallback locale determines the locale to use when the current one - | is not available. You may change the value to correspond to any of - | the language folders that are provided through your application. - | - */ - - 'fallback_locale' => 'en', - - /* - |-------------------------------------------------------------------------- - | Faker Locale - |-------------------------------------------------------------------------- - | - | This locale will be used by the Faker PHP library when generating fake - | data for your database seeds. For example, this will be used to get - | localized telephone numbers, street address information and more. - | - */ - - 'faker_locale' => 'en_US', - - /* - |-------------------------------------------------------------------------- - | Encryption Key - |-------------------------------------------------------------------------- - | - | This key is used by the Illuminate encrypter service and should be set - | to a random, 32 character string, otherwise these encrypted strings - | will not be safe. Please do this before deploying an application! - | - */ - - 'key' => env('APP_KEY'), - - 'cipher' => 'AES-256-CBC', - - /* - |-------------------------------------------------------------------------- - | Autoloaded Service Providers - |-------------------------------------------------------------------------- - | - | The service providers listed here will be automatically loaded on the - | request to your application. Feel free to add your own services to - | this array to grant expanded functionality to your applications. - | - */ - - 'providers' => [ - - /* - * Laravel Framework Service Providers... - */ - Illuminate\Auth\AuthServiceProvider::class, - Illuminate\Broadcasting\BroadcastServiceProvider::class, - Illuminate\Bus\BusServiceProvider::class, - Illuminate\Cache\CacheServiceProvider::class, - Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, - Illuminate\Cookie\CookieServiceProvider::class, - Illuminate\Database\DatabaseServiceProvider::class, - Illuminate\Encryption\EncryptionServiceProvider::class, - Illuminate\Filesystem\FilesystemServiceProvider::class, - Illuminate\Foundation\Providers\FoundationServiceProvider::class, - Illuminate\Hashing\HashServiceProvider::class, - Illuminate\Mail\MailServiceProvider::class, - Illuminate\Notifications\NotificationServiceProvider::class, - Illuminate\Pagination\PaginationServiceProvider::class, - Illuminate\Pipeline\PipelineServiceProvider::class, - Illuminate\Queue\QueueServiceProvider::class, - Illuminate\Redis\RedisServiceProvider::class, - Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, - Illuminate\Session\SessionServiceProvider::class, - Illuminate\Translation\TranslationServiceProvider::class, - Illuminate\Validation\ValidationServiceProvider::class, - Illuminate\View\ViewServiceProvider::class, - - /* - * Package Service Providers... - */ - - /* - * Application Service Providers... - */ - // App\Providers\AppServiceProvider::class, - // App\Providers\AuthServiceProvider::class, - // // App\Providers\BroadcastServiceProvider::class, - // App\Providers\EventServiceProvider::class, - // App\Providers\RouteServiceProvider::class, - - ], - - /* - |-------------------------------------------------------------------------- - | Class Aliases - |-------------------------------------------------------------------------- - | - | This array of class aliases will be registered when this application - | is started. However, feel free to register as many as you wish as - | the aliases are "lazy" loaded so they don't hinder performance. - | - */ - - 'aliases' => [ - - 'App' => Illuminate\Support\Facades\App::class, - 'Arr' => Illuminate\Support\Arr::class, - 'Artisan' => Illuminate\Support\Facades\Artisan::class, - 'Auth' => Illuminate\Support\Facades\Auth::class, - 'Blade' => Illuminate\Support\Facades\Blade::class, - 'Broadcast' => Illuminate\Support\Facades\Broadcast::class, - 'Bus' => Illuminate\Support\Facades\Bus::class, - 'Cache' => Illuminate\Support\Facades\Cache::class, - 'Config' => Illuminate\Support\Facades\Config::class, - 'Cookie' => Illuminate\Support\Facades\Cookie::class, - 'Crypt' => Illuminate\Support\Facades\Crypt::class, - 'Date' => Illuminate\Support\Facades\Date::class, - 'DB' => Illuminate\Support\Facades\DB::class, - 'Eloquent' => Illuminate\Database\Eloquent\Model::class, - 'Event' => Illuminate\Support\Facades\Event::class, - 'File' => Illuminate\Support\Facades\File::class, - 'Gate' => Illuminate\Support\Facades\Gate::class, - 'Hash' => Illuminate\Support\Facades\Hash::class, - 'Http' => Illuminate\Support\Facades\Http::class, - 'Lang' => Illuminate\Support\Facades\Lang::class, - 'Log' => Illuminate\Support\Facades\Log::class, - 'Mail' => Illuminate\Support\Facades\Mail::class, - 'Notification' => Illuminate\Support\Facades\Notification::class, - 'Password' => Illuminate\Support\Facades\Password::class, - 'Queue' => Illuminate\Support\Facades\Queue::class, - 'RateLimiter' => Illuminate\Support\Facades\RateLimiter::class, - 'Redirect' => Illuminate\Support\Facades\Redirect::class, - // 'Redis' => Illuminate\Support\Facades\Redis::class, - 'Request' => Illuminate\Support\Facades\Request::class, - 'Response' => Illuminate\Support\Facades\Response::class, - 'Route' => Illuminate\Support\Facades\Route::class, - 'Schema' => Illuminate\Support\Facades\Schema::class, - 'Session' => Illuminate\Support\Facades\Session::class, - 'Storage' => Illuminate\Support\Facades\Storage::class, - 'Str' => Illuminate\Support\Str::class, - 'URL' => Illuminate\Support\Facades\URL::class, - 'Validator' => Illuminate\Support\Facades\Validator::class, - 'View' => Illuminate\Support\Facades\View::class, - - ], - -]; diff --git a/tests/fixtures/laravel/config/auth.php b/tests/fixtures/laravel/config/auth.php deleted file mode 100644 index 0285fdf..0000000 --- a/tests/fixtures/laravel/config/auth.php +++ /dev/null @@ -1,111 +0,0 @@ - [ - 'guard' => 'web', - 'passwords' => 'users', - ], - - /* - |-------------------------------------------------------------------------- - | Authentication Guards - |-------------------------------------------------------------------------- - | - | Next, you may define every authentication guard for your application. - | Of course, a great default configuration has been defined for you - | here which uses session storage and the Eloquent user provider. - | - | All authentication drivers have a user provider. This defines how the - | users are actually retrieved out of your database or other storage - | mechanisms used by this application to persist your user's data. - | - | Supported: "session" - | - */ - - 'guards' => [ - 'web' => [ - 'driver' => 'session', - 'provider' => 'users', - ], - ], - - /* - |-------------------------------------------------------------------------- - | User Providers - |-------------------------------------------------------------------------- - | - | All authentication drivers have a user provider. This defines how the - | users are actually retrieved out of your database or other storage - | mechanisms used by this application to persist your user's data. - | - | If you have multiple user tables or models you may configure multiple - | sources which represent each model / table. These sources may then - | be assigned to any extra authentication guards you have defined. - | - | Supported: "database", "eloquent" - | - */ - - 'providers' => [ - 'users' => [ - 'driver' => 'eloquent', - 'model' => Illuminate\Foundation\Auth\User::class, - ], - - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], - ], - - /* - |-------------------------------------------------------------------------- - | Resetting Passwords - |-------------------------------------------------------------------------- - | - | You may specify multiple password reset configurations if you have more - | than one user table or model in the application and you want to have - | separate password reset settings based on the specific user types. - | - | The expire time is the number of minutes that the reset token should be - | considered valid. This security feature keeps tokens short-lived so - | they have less time to be guessed. You may change this as needed. - | - */ - - 'passwords' => [ - 'users' => [ - 'provider' => 'users', - 'table' => 'password_resets', - 'expire' => 60, - 'throttle' => 60, - ], - ], - - /* - |-------------------------------------------------------------------------- - | Password Confirmation Timeout - |-------------------------------------------------------------------------- - | - | Here you may define the amount of seconds before a password confirmation - | times out and the user is prompted to re-enter their password via the - | confirmation screen. By default, the timeout lasts for three hours. - | - */ - - 'password_timeout' => 10800, - -]; diff --git a/tests/fixtures/laravel/config/broadcasting.php b/tests/fixtures/laravel/config/broadcasting.php deleted file mode 100644 index 2d52982..0000000 --- a/tests/fixtures/laravel/config/broadcasting.php +++ /dev/null @@ -1,64 +0,0 @@ - env('BROADCAST_DRIVER', 'null'), - - /* - |-------------------------------------------------------------------------- - | Broadcast Connections - |-------------------------------------------------------------------------- - | - | Here you may define all of the broadcast connections that will be used - | to broadcast events to other systems or over websockets. Samples of - | each available type of connection are provided inside this array. - | - */ - - 'connections' => [ - - 'pusher' => [ - 'driver' => 'pusher', - 'key' => env('PUSHER_APP_KEY'), - 'secret' => env('PUSHER_APP_SECRET'), - 'app_id' => env('PUSHER_APP_ID'), - 'options' => [ - 'cluster' => env('PUSHER_APP_CLUSTER'), - 'useTLS' => true, - ], - ], - - 'ably' => [ - 'driver' => 'ably', - 'key' => env('ABLY_KEY'), - ], - - 'redis' => [ - 'driver' => 'redis', - 'connection' => 'default', - ], - - 'log' => [ - 'driver' => 'log', - ], - - 'null' => [ - 'driver' => 'null', - ], - - ], - -]; diff --git a/tests/fixtures/laravel/config/cache.php b/tests/fixtures/laravel/config/cache.php deleted file mode 100644 index 35b9068..0000000 --- a/tests/fixtures/laravel/config/cache.php +++ /dev/null @@ -1,110 +0,0 @@ - env('CACHE_DRIVER', 'array'), - - /* - |-------------------------------------------------------------------------- - | Cache Stores - |-------------------------------------------------------------------------- - | - | Here you may define all of the cache "stores" for your application as - | well as their drivers. You may even define multiple stores for the - | same cache driver to group types of items stored in your caches. - | - | Supported drivers: "apc", "array", "database", "file", - | "memcached", "redis", "dynamodb", "octane", "null" - | - */ - - 'stores' => [ - - 'apc' => [ - 'driver' => 'apc', - ], - - 'array' => [ - 'driver' => 'array', - 'serialize' => false, - ], - - 'database' => [ - 'driver' => 'database', - 'table' => 'cache', - 'connection' => null, - 'lock_connection' => null, - ], - - 'file' => [ - 'driver' => 'file', - 'path' => storage_path('framework/cache/data'), - ], - - 'memcached' => [ - 'driver' => 'memcached', - 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), - 'sasl' => [ - env('MEMCACHED_USERNAME'), - env('MEMCACHED_PASSWORD'), - ], - 'options' => [ - // Memcached::OPT_CONNECT_TIMEOUT => 2000, - ], - 'servers' => [ - [ - 'host' => env('MEMCACHED_HOST', '127.0.0.1'), - 'port' => env('MEMCACHED_PORT', 11211), - 'weight' => 100, - ], - ], - ], - - 'redis' => [ - 'driver' => 'redis', - 'connection' => 'cache', - 'lock_connection' => 'default', - ], - - 'dynamodb' => [ - 'driver' => 'dynamodb', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), - 'endpoint' => env('DYNAMODB_ENDPOINT'), - ], - - 'octane' => [ - 'driver' => 'octane', - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Cache Key Prefix - |-------------------------------------------------------------------------- - | - | When utilizing a RAM based store such as APC or Memcached, there might - | be other applications utilizing the same cache. So, we'll specify a - | value to get prefixed to all our keys so we can avoid collisions. - | - */ - - 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache'), - -]; diff --git a/tests/fixtures/laravel/config/cors.php b/tests/fixtures/laravel/config/cors.php deleted file mode 100644 index 8a39e6d..0000000 --- a/tests/fixtures/laravel/config/cors.php +++ /dev/null @@ -1,34 +0,0 @@ - ['api/*', 'sanctum/csrf-cookie'], - - 'allowed_methods' => ['*'], - - 'allowed_origins' => ['*'], - - 'allowed_origins_patterns' => [], - - 'allowed_headers' => ['*'], - - 'exposed_headers' => [], - - 'max_age' => 0, - - 'supports_credentials' => false, - -]; diff --git a/tests/fixtures/laravel/config/database.php b/tests/fixtures/laravel/config/database.php deleted file mode 100644 index 426fc9a..0000000 --- a/tests/fixtures/laravel/config/database.php +++ /dev/null @@ -1,153 +0,0 @@ - env('DB_CONNECTION', 'mysql'), - - /* - |-------------------------------------------------------------------------- - | Database Connections - |-------------------------------------------------------------------------- - | - | Here are each of the database connections setup for your application. - | Of course, examples of configuring each database platform that is - | supported by Laravel is shown below to make development simple. - | - | - | All database work in Laravel is done through the PHP PDO facilities - | so make sure you have the driver for your particular database of - | choice installed on your machine before you begin development. - | - */ - - 'connections' => [ - - 'testing' => [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', false), - ], - - 'sqlite' => [ - 'driver' => 'sqlite', - 'url' => env('DATABASE_URL'), - 'database' => env('DB_DATABASE', database_path('database.sqlite')), - 'prefix' => '', - 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - ], - - 'mysql' => [ - 'driver' => 'mysql', - 'url' => env('DATABASE_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'database' => env('DB_DATABASE', 'forge'), - 'username' => env('DB_USERNAME', 'forge'), - 'password' => env('DB_PASSWORD', ''), - 'unix_socket' => env('DB_SOCKET', ''), - 'charset' => 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', - 'prefix' => '', - 'prefix_indexes' => true, - 'strict' => true, - 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], - ], - - 'pgsql' => [ - 'driver' => 'pgsql', - 'url' => env('DATABASE_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '5432'), - 'database' => env('DB_DATABASE', 'forge'), - 'username' => env('DB_USERNAME', 'forge'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => 'utf8', - 'prefix' => '', - 'prefix_indexes' => true, - 'schema' => 'public', - 'sslmode' => 'prefer', - ], - - 'sqlsrv' => [ - 'driver' => 'sqlsrv', - 'url' => env('DATABASE_URL'), - 'host' => env('DB_HOST', 'localhost'), - 'port' => env('DB_PORT', '1433'), - 'database' => env('DB_DATABASE', 'forge'), - 'username' => env('DB_USERNAME', 'forge'), - 'password' => env('DB_PASSWORD', ''), - 'charset' => 'utf8', - 'prefix' => '', - 'prefix_indexes' => true, - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Migration Repository Table - |-------------------------------------------------------------------------- - | - | This table keeps track of all the migrations that have already run for - | your application. Using this information, we can determine which of - | the migrations on disk haven't actually been run in the database. - | - */ - - 'migrations' => 'migrations', - - /* - |-------------------------------------------------------------------------- - | Redis Databases - |-------------------------------------------------------------------------- - | - | Redis is an open source, fast, and advanced key-value store that also - | provides a richer body of commands than a typical key-value system - | such as APC or Memcached. Laravel makes it easy to dig right in. - | - */ - - 'redis' => [ - - 'client' => env('REDIS_CLIENT', 'phpredis'), - - 'options' => [ - 'cluster' => env('REDIS_CLUSTER', 'redis'), - 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'), - ], - - 'default' => [ - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', '127.0.0.1'), - 'password' => env('REDIS_PASSWORD', null), - 'port' => env('REDIS_PORT', '6379'), - 'database' => env('REDIS_DB', '0'), - ], - - 'cache' => [ - 'url' => env('REDIS_URL'), - 'host' => env('REDIS_HOST', '127.0.0.1'), - 'password' => env('REDIS_PASSWORD', null), - 'port' => env('REDIS_PORT', '6379'), - 'database' => env('REDIS_CACHE_DB', '1'), - ], - - ], - -]; diff --git a/tests/fixtures/laravel/config/filesystems.php b/tests/fixtures/laravel/config/filesystems.php deleted file mode 100644 index 760ef97..0000000 --- a/tests/fixtures/laravel/config/filesystems.php +++ /dev/null @@ -1,73 +0,0 @@ - env('FILESYSTEM_DRIVER', 'local'), - - /* - |-------------------------------------------------------------------------- - | Filesystem Disks - |-------------------------------------------------------------------------- - | - | Here you may configure as many filesystem "disks" as you wish, and you - | may even configure multiple disks of the same driver. Defaults have - | been setup for each driver as an example of the required options. - | - | Supported Drivers: "local", "ftp", "sftp", "s3" - | - */ - - 'disks' => [ - - 'local' => [ - 'driver' => 'local', - 'root' => storage_path('app'), - ], - - 'public' => [ - 'driver' => 'local', - 'root' => storage_path('app/public'), - 'url' => env('APP_URL').'/storage', - 'visibility' => 'public', - ], - - 's3' => [ - 'driver' => 's3', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'region' => env('AWS_DEFAULT_REGION'), - 'bucket' => env('AWS_BUCKET'), - 'url' => env('AWS_URL'), - 'endpoint' => env('AWS_ENDPOINT'), - 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Symbolic Links - |-------------------------------------------------------------------------- - | - | Here you may configure the symbolic links that will be created when the - | `storage:link` Artisan command is executed. The array keys should be - | the locations of the links and the values should be their targets. - | - */ - - 'links' => [ - public_path('storage') => storage_path('app/public'), - ], - -]; diff --git a/tests/fixtures/laravel/config/hashing.php b/tests/fixtures/laravel/config/hashing.php deleted file mode 100644 index 8425770..0000000 --- a/tests/fixtures/laravel/config/hashing.php +++ /dev/null @@ -1,52 +0,0 @@ - 'bcrypt', - - /* - |-------------------------------------------------------------------------- - | Bcrypt Options - |-------------------------------------------------------------------------- - | - | Here you may specify the configuration options that should be used when - | passwords are hashed using the Bcrypt algorithm. This will allow you - | to control the amount of time it takes to hash the given password. - | - */ - - 'bcrypt' => [ - 'rounds' => env('BCRYPT_ROUNDS', 10), - ], - - /* - |-------------------------------------------------------------------------- - | Argon Options - |-------------------------------------------------------------------------- - | - | Here you may specify the configuration options that should be used when - | passwords are hashed using the Argon algorithm. These will allow you - | to control the amount of time it takes to hash the given password. - | - */ - - 'argon' => [ - 'memory' => 1024, - 'threads' => 2, - 'time' => 2, - ], - -]; diff --git a/tests/fixtures/laravel/config/logging.php b/tests/fixtures/laravel/config/logging.php deleted file mode 100644 index 1aa06aa..0000000 --- a/tests/fixtures/laravel/config/logging.php +++ /dev/null @@ -1,105 +0,0 @@ - env('LOG_CHANNEL', 'stack'), - - /* - |-------------------------------------------------------------------------- - | Log Channels - |-------------------------------------------------------------------------- - | - | Here you may configure the log channels for your application. Out of - | the box, Laravel uses the Monolog PHP logging library. This gives - | you a variety of powerful log handlers / formatters to utilize. - | - | Available Drivers: "single", "daily", "slack", "syslog", - | "errorlog", "monolog", - | "custom", "stack" - | - */ - - 'channels' => [ - 'stack' => [ - 'driver' => 'stack', - 'channels' => ['single'], - 'ignore_exceptions' => false, - ], - - 'single' => [ - 'driver' => 'single', - 'path' => storage_path('logs/laravel.log'), - 'level' => env('LOG_LEVEL', 'debug'), - ], - - 'daily' => [ - 'driver' => 'daily', - 'path' => storage_path('logs/laravel.log'), - 'level' => env('LOG_LEVEL', 'debug'), - 'days' => 14, - ], - - 'slack' => [ - 'driver' => 'slack', - 'url' => env('LOG_SLACK_WEBHOOK_URL'), - 'username' => 'Laravel Log', - 'emoji' => ':boom:', - 'level' => env('LOG_LEVEL', 'critical'), - ], - - 'papertrail' => [ - 'driver' => 'monolog', - 'level' => env('LOG_LEVEL', 'debug'), - 'handler' => SyslogUdpHandler::class, - 'handler_with' => [ - 'host' => env('PAPERTRAIL_URL'), - 'port' => env('PAPERTRAIL_PORT'), - ], - ], - - 'stderr' => [ - 'driver' => 'monolog', - 'level' => env('LOG_LEVEL', 'debug'), - 'handler' => StreamHandler::class, - 'formatter' => env('LOG_STDERR_FORMATTER'), - 'with' => [ - 'stream' => 'php://stderr', - ], - ], - - 'syslog' => [ - 'driver' => 'syslog', - 'level' => env('LOG_LEVEL', 'debug'), - ], - - 'errorlog' => [ - 'driver' => 'errorlog', - 'level' => env('LOG_LEVEL', 'debug'), - ], - - 'null' => [ - 'driver' => 'monolog', - 'handler' => NullHandler::class, - ], - - 'emergency' => [ - 'path' => storage_path('logs/laravel.log'), - ], - ], - -]; diff --git a/tests/fixtures/laravel/config/mail.php b/tests/fixtures/laravel/config/mail.php deleted file mode 100644 index db96336..0000000 --- a/tests/fixtures/laravel/config/mail.php +++ /dev/null @@ -1,118 +0,0 @@ - env('MAIL_MAILER', 'smtp'), - - /* - |-------------------------------------------------------------------------- - | Mailer Configurations - |-------------------------------------------------------------------------- - | - | Here you may configure all of the mailers used by your application plus - | their respective settings. Several examples have been configured for - | you and you are free to add your own as your application requires. - | - | Laravel supports a variety of mail "transport" drivers to be used while - | sending an e-mail. You will specify which one you are using for your - | mailers below. You are free to add additional mailers as required. - | - | Supported: "smtp", "sendmail", "mailgun", "ses", - | "postmark", "log", "array" - | - */ - - 'mailers' => [ - 'smtp' => [ - 'transport' => 'smtp', - 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), - 'port' => env('MAIL_PORT', 587), - 'encryption' => env('MAIL_ENCRYPTION', 'tls'), - 'username' => env('MAIL_USERNAME'), - 'password' => env('MAIL_PASSWORD'), - 'timeout' => null, - 'auth_mode' => null, - ], - - 'ses' => [ - 'transport' => 'ses', - ], - - 'mailgun' => [ - 'transport' => 'mailgun', - ], - - 'postmark' => [ - 'transport' => 'postmark', - ], - - 'sendmail' => [ - 'transport' => 'sendmail', - 'path' => '/usr/sbin/sendmail -bs', - ], - - 'log' => [ - 'transport' => 'log', - 'channel' => env('MAIL_LOG_CHANNEL'), - ], - - 'array' => [ - 'transport' => 'array', - ], - - 'failover' => [ - 'transport' => 'failover', - 'mailers' => [ - 'smtp', - 'log', - ], - ], - ], - - /* - |-------------------------------------------------------------------------- - | Global "From" Address - |-------------------------------------------------------------------------- - | - | You may wish for all e-mails sent by your application to be sent from - | the same address. Here, you may specify a name and address that is - | used globally for all e-mails that are sent by your application. - | - */ - - 'from' => [ - 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), - 'name' => env('MAIL_FROM_NAME', 'Example'), - ], - - /* - |-------------------------------------------------------------------------- - | Markdown Mail Settings - |-------------------------------------------------------------------------- - | - | If you are using Markdown based email rendering, you may configure your - | theme and component paths here, allowing you to customize the design - | of the emails. Or, you may simply stick with the Laravel defaults! - | - */ - - 'markdown' => [ - 'theme' => 'default', - - 'paths' => [ - resource_path('views/vendor/mail'), - ], - ], - -]; diff --git a/tests/fixtures/laravel/config/queue.php b/tests/fixtures/laravel/config/queue.php deleted file mode 100644 index 25ea5a8..0000000 --- a/tests/fixtures/laravel/config/queue.php +++ /dev/null @@ -1,93 +0,0 @@ - env('QUEUE_CONNECTION', 'sync'), - - /* - |-------------------------------------------------------------------------- - | Queue Connections - |-------------------------------------------------------------------------- - | - | Here you may configure the connection information for each server that - | is used by your application. A default configuration has been added - | for each back-end shipped with Laravel. You are free to add more. - | - | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" - | - */ - - 'connections' => [ - - 'sync' => [ - 'driver' => 'sync', - ], - - 'database' => [ - 'driver' => 'database', - 'table' => 'jobs', - 'queue' => 'default', - 'retry_after' => 90, - 'after_commit' => false, - ], - - 'beanstalkd' => [ - 'driver' => 'beanstalkd', - 'host' => 'localhost', - 'queue' => 'default', - 'retry_after' => 90, - 'block_for' => 0, - 'after_commit' => false, - ], - - 'sqs' => [ - 'driver' => 'sqs', - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), - 'queue' => env('SQS_QUEUE', 'default'), - 'suffix' => env('SQS_SUFFIX'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - 'after_commit' => false, - ], - - 'redis' => [ - 'driver' => 'redis', - 'connection' => 'default', - 'queue' => env('REDIS_QUEUE', 'default'), - 'retry_after' => 90, - 'block_for' => null, - 'after_commit' => false, - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Failed Queue Jobs - |-------------------------------------------------------------------------- - | - | These options configure the behavior of failed queue job logging so you - | can control which database and table are used to store the jobs that - | have failed. You may change them to any database / table you wish. - | - */ - - 'failed' => [ - 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), - 'database' => env('DB_CONNECTION', 'mysql'), - 'table' => 'failed_jobs', - ], - -]; diff --git a/tests/fixtures/laravel/config/sanctum.php b/tests/fixtures/laravel/config/sanctum.php deleted file mode 100644 index 442726a..0000000 --- a/tests/fixtures/laravel/config/sanctum.php +++ /dev/null @@ -1,51 +0,0 @@ - explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( - '%s%s', - 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', - env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : '' - ))), - - /* - |-------------------------------------------------------------------------- - | Expiration Minutes - |-------------------------------------------------------------------------- - | - | This value controls the number of minutes until an issued token will be - | considered expired. If this value is null, personal access tokens do - | not expire. This won't tweak the lifetime of first-party sessions. - | - */ - - 'expiration' => null, - - /* - |-------------------------------------------------------------------------- - | Sanctum Middleware - |-------------------------------------------------------------------------- - | - | When authenticating your first-party SPA with Sanctum you may need to - | customize some of the middleware Sanctum uses while processing the - | request. You may change the middleware listed below as required. - | - */ - - 'middleware' => [ - 'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class, - 'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class, - ], - -]; diff --git a/tests/fixtures/laravel/config/services.php b/tests/fixtures/laravel/config/services.php deleted file mode 100644 index 2a1d616..0000000 --- a/tests/fixtures/laravel/config/services.php +++ /dev/null @@ -1,33 +0,0 @@ - [ - 'domain' => env('MAILGUN_DOMAIN'), - 'secret' => env('MAILGUN_SECRET'), - 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), - ], - - 'postmark' => [ - 'token' => env('POSTMARK_TOKEN'), - ], - - 'ses' => [ - 'key' => env('AWS_ACCESS_KEY_ID'), - 'secret' => env('AWS_SECRET_ACCESS_KEY'), - 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), - ], - -]; diff --git a/tests/fixtures/laravel/config/session.php b/tests/fixtures/laravel/config/session.php deleted file mode 100644 index ec77e0c..0000000 --- a/tests/fixtures/laravel/config/session.php +++ /dev/null @@ -1,201 +0,0 @@ - env('SESSION_DRIVER', 'array'), - - /* - |-------------------------------------------------------------------------- - | Session Lifetime - |-------------------------------------------------------------------------- - | - | Here you may specify the number of minutes that you wish the session - | to be allowed to remain idle before it expires. If you want them - | to immediately expire on the browser closing, set that option. - | - */ - - 'lifetime' => env('SESSION_LIFETIME', 120), - - 'expire_on_close' => false, - - /* - |-------------------------------------------------------------------------- - | Session Encryption - |-------------------------------------------------------------------------- - | - | This option allows you to easily specify that all of your session data - | should be encrypted before it is stored. All encryption will be run - | automatically by Laravel and you can use the Session like normal. - | - */ - - 'encrypt' => false, - - /* - |-------------------------------------------------------------------------- - | Session File Location - |-------------------------------------------------------------------------- - | - | When using the native session driver, we need a location where session - | files may be stored. A default has been set for you but a different - | location may be specified. This is only needed for file sessions. - | - */ - - 'files' => storage_path('framework/sessions'), - - /* - |-------------------------------------------------------------------------- - | Session Database Connection - |-------------------------------------------------------------------------- - | - | When using the "database" or "redis" session drivers, you may specify a - | connection that should be used to manage these sessions. This should - | correspond to a connection in your database configuration options. - | - */ - - 'connection' => env('SESSION_CONNECTION', null), - - /* - |-------------------------------------------------------------------------- - | Session Database Table - |-------------------------------------------------------------------------- - | - | When using the "database" session driver, you may specify the table we - | should use to manage the sessions. Of course, a sensible default is - | provided for you; however, you are free to change this as needed. - | - */ - - 'table' => 'sessions', - - /* - |-------------------------------------------------------------------------- - | Session Cache Store - |-------------------------------------------------------------------------- - | - | While using one of the framework's cache driven session backends you may - | list a cache store that should be used for these sessions. This value - | must match with one of the application's configured cache "stores". - | - | Affects: "apc", "dynamodb", "memcached", "redis" - | - */ - - 'store' => env('SESSION_STORE', null), - - /* - |-------------------------------------------------------------------------- - | Session Sweeping Lottery - |-------------------------------------------------------------------------- - | - | Some session drivers must manually sweep their storage location to get - | rid of old sessions from storage. Here are the chances that it will - | happen on a given request. By default, the odds are 2 out of 100. - | - */ - - 'lottery' => [2, 100], - - /* - |-------------------------------------------------------------------------- - | Session Cookie Name - |-------------------------------------------------------------------------- - | - | Here you may change the name of the cookie used to identify a session - | instance by ID. The name specified here will get used every time a - | new session cookie is created by the framework for every driver. - | - */ - - 'cookie' => env( - 'SESSION_COOKIE', - Str::slug(env('APP_NAME', 'laravel'), '_').'_session' - ), - - /* - |-------------------------------------------------------------------------- - | Session Cookie Path - |-------------------------------------------------------------------------- - | - | The session cookie path determines the path for which the cookie will - | be regarded as available. Typically, this will be the root path of - | your application but you are free to change this when necessary. - | - */ - - 'path' => '/', - - /* - |-------------------------------------------------------------------------- - | Session Cookie Domain - |-------------------------------------------------------------------------- - | - | Here you may change the domain of the cookie used to identify a session - | in your application. This will determine which domains the cookie is - | available to in your application. A sensible default has been set. - | - */ - - 'domain' => env('SESSION_DOMAIN', null), - - /* - |-------------------------------------------------------------------------- - | HTTPS Only Cookies - |-------------------------------------------------------------------------- - | - | By setting this option to true, session cookies will only be sent back - | to the server if the browser has a HTTPS connection. This will keep - | the cookie from being sent to you when it can't be done securely. - | - */ - - 'secure' => env('SESSION_SECURE_COOKIE'), - - /* - |-------------------------------------------------------------------------- - | HTTP Access Only - |-------------------------------------------------------------------------- - | - | Setting this value to true will prevent JavaScript from accessing the - | value of the cookie and the cookie will only be accessible through - | the HTTP protocol. You are free to modify this option if needed. - | - */ - - 'http_only' => true, - - /* - |-------------------------------------------------------------------------- - | Same-Site Cookies - |-------------------------------------------------------------------------- - | - | This option determines how your cookies behave when cross-site requests - | take place, and can be used to mitigate CSRF attacks. By default, we - | will set this value to "lax" since this is a secure default value. - | - | Supported: "lax", "strict", "none", null - | - */ - - 'same_site' => 'lax', - -]; diff --git a/tests/fixtures/laravel/config/view.php b/tests/fixtures/laravel/config/view.php deleted file mode 100644 index 22b8a18..0000000 --- a/tests/fixtures/laravel/config/view.php +++ /dev/null @@ -1,36 +0,0 @@ - [ - resource_path('views'), - ], - - /* - |-------------------------------------------------------------------------- - | Compiled View Path - |-------------------------------------------------------------------------- - | - | This option determines where all the compiled Blade templates will be - | stored for your application. Typically, this is within the storage - | directory. However, as usual, you are free to change this value. - | - */ - - 'compiled' => env( - 'VIEW_COMPILED_PATH', - realpath(storage_path('framework/views')) - ), - -]; diff --git a/tests/fixtures/laravel/database/.gitignore b/tests/fixtures/laravel/database/.gitignore deleted file mode 100644 index 9b19b93..0000000 --- a/tests/fixtures/laravel/database/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.sqlite* diff --git a/tests/fixtures/laravel/database/factories/.gitkeep b/tests/fixtures/laravel/database/factories/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/fixtures/laravel/database/migrations/.gitkeep b/tests/fixtures/laravel/database/migrations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/fixtures/laravel/database/seeders/.gitkeep b/tests/fixtures/laravel/database/seeders/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/fixtures/laravel/migrations/2014_10_12_000000_testbench_create_users_table.php b/tests/fixtures/laravel/migrations/2014_10_12_000000_testbench_create_users_table.php deleted file mode 100644 index 288afdb..0000000 --- a/tests/fixtures/laravel/migrations/2014_10_12_000000_testbench_create_users_table.php +++ /dev/null @@ -1,36 +0,0 @@ -id(); - $table->string('name'); - $table->string('email')->unique(); - $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); - $table->rememberToken(); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('users'); - } -} diff --git a/tests/fixtures/laravel/migrations/2014_10_12_100000_testbench_create_password_resets_table.php b/tests/fixtures/laravel/migrations/2014_10_12_100000_testbench_create_password_resets_table.php deleted file mode 100644 index 4c54106..0000000 --- a/tests/fixtures/laravel/migrations/2014_10_12_100000_testbench_create_password_resets_table.php +++ /dev/null @@ -1,32 +0,0 @@ -string('email')->index(); - $table->string('token'); - $table->timestamp('created_at')->nullable(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('password_resets'); - } -} diff --git a/tests/fixtures/laravel/migrations/2019_08_19_000000_testbench_create_failed_jobs_table.php b/tests/fixtures/laravel/migrations/2019_08_19_000000_testbench_create_failed_jobs_table.php deleted file mode 100644 index ea47866..0000000 --- a/tests/fixtures/laravel/migrations/2019_08_19_000000_testbench_create_failed_jobs_table.php +++ /dev/null @@ -1,36 +0,0 @@ -id(); - $table->string('uuid')->unique(); - $table->text('connection'); - $table->text('queue'); - $table->longText('payload'); - $table->longText('exception'); - $table->timestamp('failed_at')->useCurrent(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('failed_jobs'); - } -} diff --git a/tests/fixtures/laravel/public/.gitignore b/tests/fixtures/laravel/public/.gitignore deleted file mode 100644 index d723e54..0000000 --- a/tests/fixtures/laravel/public/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -* -!index.php -!.gitignore diff --git a/tests/fixtures/laravel/public/index.php b/tests/fixtures/laravel/public/index.php deleted file mode 100644 index 66ea93c..0000000 --- a/tests/fixtures/laravel/public/index.php +++ /dev/null @@ -1,55 +0,0 @@ -make(Kernel::class); - -$response = tap($kernel->handle( - $request = Request::capture() -))->send(); - -$kernel->terminate($request, $response); diff --git a/tests/fixtures/laravel/resources/lang/en/auth.php b/tests/fixtures/laravel/resources/lang/en/auth.php deleted file mode 100644 index 6598e2c..0000000 --- a/tests/fixtures/laravel/resources/lang/en/auth.php +++ /dev/null @@ -1,20 +0,0 @@ - 'These credentials do not match our records.', - 'password' => 'The provided password is incorrect.', - 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', - -]; diff --git a/tests/fixtures/laravel/resources/lang/en/pagination.php b/tests/fixtures/laravel/resources/lang/en/pagination.php deleted file mode 100644 index d481411..0000000 --- a/tests/fixtures/laravel/resources/lang/en/pagination.php +++ /dev/null @@ -1,19 +0,0 @@ - '« Previous', - 'next' => 'Next »', - -]; diff --git a/tests/fixtures/laravel/resources/lang/en/passwords.php b/tests/fixtures/laravel/resources/lang/en/passwords.php deleted file mode 100644 index 2345a56..0000000 --- a/tests/fixtures/laravel/resources/lang/en/passwords.php +++ /dev/null @@ -1,22 +0,0 @@ - 'Your password has been reset!', - 'sent' => 'We have emailed your password reset link!', - 'throttled' => 'Please wait before retrying.', - 'token' => 'This password reset token is invalid.', - 'user' => "We can't find a user with that email address.", - -]; diff --git a/tests/fixtures/laravel/resources/lang/en/validation.php b/tests/fixtures/laravel/resources/lang/en/validation.php deleted file mode 100644 index 2179113..0000000 --- a/tests/fixtures/laravel/resources/lang/en/validation.php +++ /dev/null @@ -1,158 +0,0 @@ - 'The :attribute must be accepted.', - 'accepted_if' => 'The :attribute must be accepted when :other is :value.', - 'active_url' => 'The :attribute is not a valid URL.', - 'after' => 'The :attribute must be a date after :date.', - 'after_or_equal' => 'The :attribute must be a date after or equal to :date.', - 'alpha' => 'The :attribute must only contain letters.', - 'alpha_dash' => 'The :attribute must only contain letters, numbers, dashes and underscores.', - 'alpha_num' => 'The :attribute must only contain letters and numbers.', - 'array' => 'The :attribute must be an array.', - 'before' => 'The :attribute must be a date before :date.', - 'before_or_equal' => 'The :attribute must be a date before or equal to :date.', - 'between' => [ - 'numeric' => 'The :attribute must be between :min and :max.', - 'file' => 'The :attribute must be between :min and :max kilobytes.', - 'string' => 'The :attribute must be between :min and :max characters.', - 'array' => 'The :attribute must have between :min and :max items.', - ], - 'boolean' => 'The :attribute field must be true or false.', - 'confirmed' => 'The :attribute confirmation does not match.', - 'current_password' => 'The password is incorrect.', - 'date' => 'The :attribute is not a valid date.', - 'date_equals' => 'The :attribute must be a date equal to :date.', - 'date_format' => 'The :attribute does not match the format :format.', - 'different' => 'The :attribute and :other must be different.', - 'digits' => 'The :attribute must be :digits digits.', - 'digits_between' => 'The :attribute must be between :min and :max digits.', - 'dimensions' => 'The :attribute has invalid image dimensions.', - 'distinct' => 'The :attribute field has a duplicate value.', - 'email' => 'The :attribute must be a valid email address.', - 'ends_with' => 'The :attribute must end with one of the following: :values.', - 'exists' => 'The selected :attribute is invalid.', - 'file' => 'The :attribute must be a file.', - 'filled' => 'The :attribute field must have a value.', - 'gt' => [ - 'numeric' => 'The :attribute must be greater than :value.', - 'file' => 'The :attribute must be greater than :value kilobytes.', - 'string' => 'The :attribute must be greater than :value characters.', - 'array' => 'The :attribute must have more than :value items.', - ], - 'gte' => [ - 'numeric' => 'The :attribute must be greater than or equal :value.', - 'file' => 'The :attribute must be greater than or equal :value kilobytes.', - 'string' => 'The :attribute must be greater than or equal :value characters.', - 'array' => 'The :attribute must have :value items or more.', - ], - 'image' => 'The :attribute must be an image.', - 'in' => 'The selected :attribute is invalid.', - 'in_array' => 'The :attribute field does not exist in :other.', - 'integer' => 'The :attribute must be an integer.', - 'ip' => 'The :attribute must be a valid IP address.', - 'ipv4' => 'The :attribute must be a valid IPv4 address.', - 'ipv6' => 'The :attribute must be a valid IPv6 address.', - 'json' => 'The :attribute must be a valid JSON string.', - 'lt' => [ - 'numeric' => 'The :attribute must be less than :value.', - 'file' => 'The :attribute must be less than :value kilobytes.', - 'string' => 'The :attribute must be less than :value characters.', - 'array' => 'The :attribute must have less than :value items.', - ], - 'lte' => [ - 'numeric' => 'The :attribute must be less than or equal :value.', - 'file' => 'The :attribute must be less than or equal :value kilobytes.', - 'string' => 'The :attribute must be less than or equal :value characters.', - 'array' => 'The :attribute must not have more than :value items.', - ], - 'max' => [ - 'numeric' => 'The :attribute must not be greater than :max.', - 'file' => 'The :attribute must not be greater than :max kilobytes.', - 'string' => 'The :attribute must not be greater than :max characters.', - 'array' => 'The :attribute must not have more than :max items.', - ], - 'mimes' => 'The :attribute must be a file of type: :values.', - 'mimetypes' => 'The :attribute must be a file of type: :values.', - 'min' => [ - 'numeric' => 'The :attribute must be at least :min.', - 'file' => 'The :attribute must be at least :min kilobytes.', - 'string' => 'The :attribute must be at least :min characters.', - 'array' => 'The :attribute must have at least :min items.', - ], - 'multiple_of' => 'The :attribute must be a multiple of :value.', - 'not_in' => 'The selected :attribute is invalid.', - 'not_regex' => 'The :attribute format is invalid.', - 'numeric' => 'The :attribute must be a number.', - 'password' => 'The password is incorrect.', - 'present' => 'The :attribute field must be present.', - 'regex' => 'The :attribute format is invalid.', - 'required' => 'The :attribute field is required.', - 'required_if' => 'The :attribute field is required when :other is :value.', - 'required_unless' => 'The :attribute field is required unless :other is in :values.', - 'required_with' => 'The :attribute field is required when :values is present.', - 'required_with_all' => 'The :attribute field is required when :values are present.', - 'required_without' => 'The :attribute field is required when :values is not present.', - 'required_without_all' => 'The :attribute field is required when none of :values are present.', - 'prohibited' => 'The :attribute field is prohibited.', - 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', - 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', - 'prohibits' => 'The :attribute field prohibits :other from being present.', - 'same' => 'The :attribute and :other must match.', - 'size' => [ - 'numeric' => 'The :attribute must be :size.', - 'file' => 'The :attribute must be :size kilobytes.', - 'string' => 'The :attribute must be :size characters.', - 'array' => 'The :attribute must contain :size items.', - ], - 'starts_with' => 'The :attribute must start with one of the following: :values.', - 'string' => 'The :attribute must be a string.', - 'timezone' => 'The :attribute must be a valid timezone.', - 'unique' => 'The :attribute has already been taken.', - 'uploaded' => 'The :attribute failed to upload.', - 'url' => 'The :attribute must be a valid URL.', - 'uuid' => 'The :attribute must be a valid UUID.', - - /* - |-------------------------------------------------------------------------- - | Custom Validation Language Lines - |-------------------------------------------------------------------------- - | - | Here you may specify custom validation messages for attributes using the - | convention "attribute.rule" to name the lines. This makes it quick to - | specify a specific custom language line for a given attribute rule. - | - */ - - 'custom' => [ - 'attribute-name' => [ - 'rule-name' => 'custom-message', - ], - ], - - /* - |-------------------------------------------------------------------------- - | Custom Validation Attributes - |-------------------------------------------------------------------------- - | - | The following language lines are used to swap our attribute placeholder - | with something more reader friendly such as "E-Mail Address" instead - | of "email". This simply helps us make our message more expressive. - | - */ - - 'attributes' => [], - -]; diff --git a/tests/fixtures/laravel/resources/views/errors/503.blade.php b/tests/fixtures/laravel/resources/views/errors/503.blade.php deleted file mode 100644 index 336936b..0000000 --- a/tests/fixtures/laravel/resources/views/errors/503.blade.php +++ /dev/null @@ -1,43 +0,0 @@ - - - Be right back. - - - - - - -
-
-
Be right back.
-
-
- - diff --git a/tests/fixtures/laravel/resources/views/welcome.blade.php b/tests/fixtures/laravel/resources/views/welcome.blade.php deleted file mode 100644 index 284db8e..0000000 --- a/tests/fixtures/laravel/resources/views/welcome.blade.php +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - -
-
-
Laravel 5
-
-
- - diff --git a/tests/fixtures/laravel/routes/.gitignore b/tests/fixtures/laravel/routes/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/tests/fixtures/laravel/routes/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/fixtures/laravel/server.php b/tests/fixtures/laravel/server.php deleted file mode 100644 index 5fb6379..0000000 --- a/tests/fixtures/laravel/server.php +++ /dev/null @@ -1,21 +0,0 @@ - - */ - -$uri = urldecode( - parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) -); - -// This file allows us to emulate Apache's "mod_rewrite" functionality from the -// built-in PHP web server. This provides a convenient way to test a Laravel -// application without having installed a "real" web server software here. -if ($uri !== '/' && file_exists(__DIR__.'/public'.$uri)) { - return false; -} - -require_once __DIR__.'/public/index.php'; diff --git a/tests/fixtures/laravel/storage/app/.gitignore b/tests/fixtures/laravel/storage/app/.gitignore deleted file mode 100644 index e69de29..0000000 diff --git a/tests/fixtures/laravel/storage/app/modules/modules.used b/tests/fixtures/laravel/storage/app/modules/modules.used deleted file mode 100644 index 63ebfb4..0000000 --- a/tests/fixtures/laravel/storage/app/modules/modules.used +++ /dev/null @@ -1 +0,0 @@ -Article \ No newline at end of file diff --git a/tests/fixtures/laravel/storage/app/public/.gitignore b/tests/fixtures/laravel/storage/app/public/.gitignore deleted file mode 100644 index e69de29..0000000 diff --git a/tests/fixtures/laravel/storage/framework/.gitignore b/tests/fixtures/laravel/storage/framework/.gitignore deleted file mode 100644 index cd61cda..0000000 --- a/tests/fixtures/laravel/storage/framework/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -down -schedule-* diff --git a/tests/fixtures/laravel/storage/framework/cache/.gitignore b/tests/fixtures/laravel/storage/framework/cache/.gitignore deleted file mode 100644 index 01e4a6c..0000000 --- a/tests/fixtures/laravel/storage/framework/cache/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -* -!data/ -!.gitignore diff --git a/tests/fixtures/laravel/storage/framework/data/.gitignore b/tests/fixtures/laravel/storage/framework/data/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/tests/fixtures/laravel/storage/framework/data/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/fixtures/laravel/storage/framework/sessions/.gitignore b/tests/fixtures/laravel/storage/framework/sessions/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/tests/fixtures/laravel/storage/framework/sessions/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/fixtures/laravel/storage/framework/testing/.gitignore b/tests/fixtures/laravel/storage/framework/testing/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/tests/fixtures/laravel/storage/framework/testing/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/fixtures/laravel/storage/framework/views/.gitignore b/tests/fixtures/laravel/storage/framework/views/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/tests/fixtures/laravel/storage/framework/views/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/fixtures/laravel/storage/logs/.gitignore b/tests/fixtures/laravel/storage/logs/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/tests/fixtures/laravel/storage/logs/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/fixtures/stubs/model.stub b/tests/fixtures/stubs/model.stub deleted file mode 100644 index 1002d79..0000000 --- a/tests/fixtures/stubs/model.stub +++ /dev/null @@ -1 +0,0 @@ -stub-override \ No newline at end of file diff --git a/tests/fixtures/stubs/modules/invalid/without-name/composer.json b/tests/fixtures/stubs/modules/invalid/without-name/composer.json deleted file mode 100644 index 0906c69..0000000 --- a/tests/fixtures/stubs/modules/invalid/without-name/composer.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "", - "description": "Invalid module without name", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\WithoutName\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\WithoutName\\Tests\\": "tests/" - } - } -} diff --git a/tests/fixtures/stubs/modules/invalid/without-namespace/composer.json b/tests/fixtures/stubs/modules/invalid/without-namespace/composer.json deleted file mode 100644 index 9b65e62..0000000 --- a/tests/fixtures/stubs/modules/invalid/without-namespace/composer.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "laraneat/without-namespace", - "description": "Invalid module without namespace", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ] -} diff --git a/tests/fixtures/stubs/modules/valid/article-category/composer.json b/tests/fixtures/stubs/modules/valid/article-category/composer.json deleted file mode 100644 index 792a48f..0000000 --- a/tests/fixtures/stubs/modules/valid/article-category/composer.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "laraneat/article-category", - "description": "ArticleCategory module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\ArticleCategory\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\ArticleCategory\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\ArticleCategory\\Providers\\ArticleCategoryServiceProvider", - "Modules\\ArticleCategory\\Providers\\RouteServiceProvider" - ], - "aliases": [] - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/article-category/src/Providers/ArticleCategoryServiceProvider.php b/tests/fixtures/stubs/modules/valid/article-category/src/Providers/ArticleCategoryServiceProvider.php deleted file mode 100644 index a845107..0000000 --- a/tests/fixtures/stubs/modules/valid/article-category/src/Providers/ArticleCategoryServiceProvider.php +++ /dev/null @@ -1,89 +0,0 @@ -loadMigrations(); - // $this->loadTranslations(); - // $this->loadCommands(); - // $this->loadViews(); - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = realpath('../../lang'); - $langPath = $this->app->langPath('modules/' . $this->modulePackageName); - - $this->loadTranslationsFrom($sourcePath, $this->modulePackageName); - - $this->publishes([ - $sourcePath => $langPath, - ], 'article-category-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = realpath('../../resources/views'); - $viewsPath = $this->app->resourcePath('views/modules/' . $this->modulePackageName); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths($this->modulePackageName), [$sourcePath]), - $this->modulePackageName - ); - - $this->publishes([ - $sourcePath => $viewsPath, - ], 'article-category-views'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = realpath('../../database/migrations'); - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath, - ], 'article-category-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\ArticleCategory\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/article-category/src/Providers/RouteServiceProvider.php b/tests/fixtures/stubs/modules/valid/article-category/src/Providers/RouteServiceProvider.php deleted file mode 100644 index 8cfa02b..0000000 --- a/tests/fixtures/stubs/modules/valid/article-category/src/Providers/RouteServiceProvider.php +++ /dev/null @@ -1,57 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\ArticleCategory\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/WEB/routes')); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\ArticleCategory\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/API/routes')); - }); - } -} diff --git a/tests/fixtures/stubs/modules/valid/article-copy/composer.json b/tests/fixtures/stubs/modules/valid/article-copy/composer.json deleted file mode 100644 index 02d9b7b..0000000 --- a/tests/fixtures/stubs/modules/valid/article-copy/composer.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "laraneat/article", - "description": "Article module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Article\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Article\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Article\\Providers\\ArticleServiceProvider", - "Modules\\Article\\Providers\\RouteServiceProvider" - ], - "aliases": [] - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/article-copy/src/Providers/ArticleServiceProvider.php b/tests/fixtures/stubs/modules/valid/article-copy/src/Providers/ArticleServiceProvider.php deleted file mode 100644 index 48ccbe3..0000000 --- a/tests/fixtures/stubs/modules/valid/article-copy/src/Providers/ArticleServiceProvider.php +++ /dev/null @@ -1,89 +0,0 @@ -loadMigrations(); - // $this->loadTranslations(); - // $this->loadCommands(); - // $this->loadViews(); - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = realpath('../../lang'); - $langPath = $this->app->langPath('modules/' . $this->modulePackageName); - - $this->loadTranslationsFrom($sourcePath, $this->modulePackageName); - - $this->publishes([ - $sourcePath => $langPath, - ], 'article-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = realpath('../../resources/views'); - $viewsPath = $this->app->resourcePath('views/modules/' . $this->modulePackageName); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths($this->modulePackageName), [$sourcePath]), - $this->modulePackageName - ); - - $this->publishes([ - $sourcePath => $viewsPath, - ], 'article-views'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = realpath('../../database/migrations'); - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath, - ], 'article-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\Article\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/article-copy/src/Providers/RouteServiceProvider.php b/tests/fixtures/stubs/modules/valid/article-copy/src/Providers/RouteServiceProvider.php deleted file mode 100644 index e20139c..0000000 --- a/tests/fixtures/stubs/modules/valid/article-copy/src/Providers/RouteServiceProvider.php +++ /dev/null @@ -1,57 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\Article\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/WEB/routes')); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\Article\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/API/routes')); - }); - } -} diff --git a/tests/fixtures/stubs/modules/valid/article/composer.json b/tests/fixtures/stubs/modules/valid/article/composer.json deleted file mode 100644 index 02d9b7b..0000000 --- a/tests/fixtures/stubs/modules/valid/article/composer.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "laraneat/article", - "description": "Article module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Article\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Article\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Article\\Providers\\ArticleServiceProvider", - "Modules\\Article\\Providers\\RouteServiceProvider" - ], - "aliases": [] - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/article/src/Providers/ArticleServiceProvider.php b/tests/fixtures/stubs/modules/valid/article/src/Providers/ArticleServiceProvider.php deleted file mode 100644 index 48ccbe3..0000000 --- a/tests/fixtures/stubs/modules/valid/article/src/Providers/ArticleServiceProvider.php +++ /dev/null @@ -1,89 +0,0 @@ -loadMigrations(); - // $this->loadTranslations(); - // $this->loadCommands(); - // $this->loadViews(); - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = realpath('../../lang'); - $langPath = $this->app->langPath('modules/' . $this->modulePackageName); - - $this->loadTranslationsFrom($sourcePath, $this->modulePackageName); - - $this->publishes([ - $sourcePath => $langPath, - ], 'article-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = realpath('../../resources/views'); - $viewsPath = $this->app->resourcePath('views/modules/' . $this->modulePackageName); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths($this->modulePackageName), [$sourcePath]), - $this->modulePackageName - ); - - $this->publishes([ - $sourcePath => $viewsPath, - ], 'article-views'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = realpath('../../database/migrations'); - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath, - ], 'article-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\Article\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/article/src/Providers/RouteServiceProvider.php b/tests/fixtures/stubs/modules/valid/article/src/Providers/RouteServiceProvider.php deleted file mode 100644 index e20139c..0000000 --- a/tests/fixtures/stubs/modules/valid/article/src/Providers/RouteServiceProvider.php +++ /dev/null @@ -1,57 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\Article\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/WEB/routes')); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\Article\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/API/routes')); - }); - } -} diff --git a/tests/fixtures/stubs/modules/valid/author/composer.json b/tests/fixtures/stubs/modules/valid/author/composer.json deleted file mode 100644 index da14fd9..0000000 --- a/tests/fixtures/stubs/modules/valid/author/composer.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "laraneat/author", - "description": "Author module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Author\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\Author\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\Author\\Providers\\AuthorServiceProvider", - "Modules\\Author\\Providers\\RouteServiceProvider" - ], - "aliases": { - "AuthorFacade": "Modules\\Author\\Facades\\SomeFacade" - } - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/author/src/Facades/SomeFacade.php b/tests/fixtures/stubs/modules/valid/author/src/Facades/SomeFacade.php deleted file mode 100644 index bee9723..0000000 --- a/tests/fixtures/stubs/modules/valid/author/src/Facades/SomeFacade.php +++ /dev/null @@ -1,7 +0,0 @@ -loadMigrations(); - // $this->loadTranslations(); - // $this->loadCommands(); - // $this->loadViews(); - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = realpath('../../lang'); - $langPath = $this->app->langPath('modules/' . $this->modulePackageName); - - $this->loadTranslationsFrom($sourcePath, $this->modulePackageName); - - $this->publishes([ - $sourcePath => $langPath, - ], 'author-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = realpath('../../resources/views'); - $viewsPath = $this->app->resourcePath('views/modules/' . $this->modulePackageName); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths($this->modulePackageName), [$sourcePath]), - $this->modulePackageName - ); - - $this->publishes([ - $sourcePath => $viewsPath, - ], 'author-views'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = realpath('../../database/migrations'); - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath, - ], 'author-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\Author\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/author/src/Providers/RouteServiceProvider.php b/tests/fixtures/stubs/modules/valid/author/src/Providers/RouteServiceProvider.php deleted file mode 100644 index a9eff6d..0000000 --- a/tests/fixtures/stubs/modules/valid/author/src/Providers/RouteServiceProvider.php +++ /dev/null @@ -1,59 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\Author\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/WEB/Routes')); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\Author\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/API/Routes')); - }); - } -} diff --git a/tests/fixtures/stubs/modules/valid/empty-module/composer.json b/tests/fixtures/stubs/modules/valid/empty-module/composer.json deleted file mode 100644 index be56de9..0000000 --- a/tests/fixtures/stubs/modules/valid/empty-module/composer.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "laraneat/empty", - "description": "Empty module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\Empty\\": "src/" - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/empty/composer.json b/tests/fixtures/stubs/modules/valid/empty/composer.json deleted file mode 100644 index f8d6492..0000000 --- a/tests/fixtures/stubs/modules/valid/empty/composer.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "empty/empty", - "description": "Empty module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Empty\\Empty\\": "src/" - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/navigation/composer.json b/tests/fixtures/stubs/modules/valid/navigation/composer.json deleted file mode 100644 index f27c35b..0000000 --- a/tests/fixtures/stubs/modules/valid/navigation/composer.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "laraneat/location", - "description": "GeoLocation module for testing", - "authors": [ - { - "name": "Salavat Salakhutdinov", - "email": "salahutdinov.salavat@gmail.com" - } - ], - "autoload": { - "psr-4": { - "Modules\\GeoLocation\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\GeoLocation\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\GeoLocation\\Providers\\GeoLocationServiceProvider", - "Modules\\GeoLocation\\Providers\\RouteServiceProvider" - ], - "aliases": [] - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/navigation/src/Providers/GeoLocationServiceProvider.php b/tests/fixtures/stubs/modules/valid/navigation/src/Providers/GeoLocationServiceProvider.php deleted file mode 100644 index 979cd4c..0000000 --- a/tests/fixtures/stubs/modules/valid/navigation/src/Providers/GeoLocationServiceProvider.php +++ /dev/null @@ -1,89 +0,0 @@ -loadMigrations(); - // $this->loadTranslations(); - // $this->loadCommands(); - // $this->loadViews(); - } - - /** - * Register translations. - */ - public function loadTranslations(): void - { - $sourcePath = realpath('../../lang'); - $langPath = $this->app->langPath('modules/' . $this->modulePackageName); - - $this->loadTranslationsFrom($sourcePath, $this->modulePackageName); - - $this->publishes([ - $sourcePath => $langPath, - ], 'geo-location-translations'); - } - - /** - * Register views. - */ - public function loadViews(): void - { - $sourcePath = realpath('../../resources/views'); - $viewsPath = $this->app->resourcePath('views/modules/' . $this->modulePackageName); - - $this->loadViewsFrom( - array_merge($this->getPublishableViewPaths($this->modulePackageName), [$sourcePath]), - $this->modulePackageName - ); - - $this->publishes([ - $sourcePath => $viewsPath, - ], 'geo-location-views'); - } - - /** - * Register migrations. - */ - public function loadMigrations(): void - { - $sourcePath = realpath('../../database/migrations'); - $migrationsPath = $this->app->databasePath('migrations'); - - $this->loadMigrationsFrom($sourcePath); - - $this->publishes([ - $sourcePath => $migrationsPath, - ], 'geo-location-migrations'); - } - - /** - * Register artisan commands. - */ - public function loadCommands(): void - { - if ($this->app->runningInConsole()) { - $this->loadCommandsFrom([ - 'Modules\\GeoLocation\\UI\\CLI\\Commands' => __DIR__.'/../UI/CLI/Commands', - ]); - } - } -} diff --git a/tests/fixtures/stubs/modules/valid/navigation/src/Providers/RouteServiceProvider.php b/tests/fixtures/stubs/modules/valid/navigation/src/Providers/RouteServiceProvider.php deleted file mode 100644 index 7bbbe04..0000000 --- a/tests/fixtures/stubs/modules/valid/navigation/src/Providers/RouteServiceProvider.php +++ /dev/null @@ -1,57 +0,0 @@ -mapApiRoutes(); - $this->mapWebRoutes(); - } - - /** - * Define the "web" routes for the application. - * These routes all receive session state, CSRF protection, etc. - */ - protected function mapWebRoutes(): void - { - Route::middleware('web') - // ->namespace('Modules\\GeoLocation\\UI\\WEB\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/WEB/routes')); - }); - } - - /** - * Define the "api" routes for the application. - * These routes are typically stateless. - */ - protected function mapApiRoutes(): void - { - Route::prefix('api') - ->middleware('api') - // ->namespace('Modules\\GeoLocation\\UI\\API\\Controllers') - ->group(function () { - $this->loadRoutesFromDirectory(realpath('../UI/API/routes')); - }); - } -} From fca0ed9fe2ce1be07e636286da7fbc4953611a7e Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 09:36:38 +0300 Subject: [PATCH 02/38] Set up the 3.x tooling Require PHP 8.3 and Laravel 13.12, drop composer/composer, and move to Pest, Pint and PHPStan at the max level without a baseline. CI runs PHP 8.3 to 8.5 with the lowest and the latest dependencies, checks coverage and the mutation score, and audits the dependencies. --- .gitattributes | 10 +++++ .github/workflows/tests.yml | 76 ++++++++++++++++++++++--------------- composer.json | 69 +++++++++++++++------------------ phpstan.neon.dist | 16 +------- phpunit.xml | 33 ++++++++++++++++ pint.json | 10 +++++ 6 files changed, 130 insertions(+), 84 deletions(-) create mode 100644 .gitattributes create mode 100644 phpunit.xml create mode 100644 pint.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d70725d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +* text=auto eol=lf + +/.github export-ignore +/tests export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml export-ignore +/pint.json export-ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 72dfb05..3fc2436 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,31 +2,37 @@ name: Tests on: push: - branches: [master, 2.x] + branches: [master, 3.x] pull_request: - branches: [master, 2.x] + branches: [master, 3.x] permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.experimental }} strategy: fail-fast: false matrix: - php: ['8.1', '8.2', '8.3', '8.4', '8.5'] - laravel: ['10.*', '11.*', '12.*', '13.*'] - exclude: - - { php: '8.1', laravel: '11.*' } - - { php: '8.1', laravel: '12.*' } - - { php: '8.1', laravel: '13.*' } - - { php: '8.2', laravel: '13.*' } - - { php: '8.5', laravel: '10.*' } - - { php: '8.5', laravel: '11.*' } - - name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} + os: [ubuntu-latest] + php: ['8.3', '8.4', '8.5'] + dependencies: [stable] + framework: [''] + experimental: [false] + include: + - { os: ubuntu-latest, php: '8.3', dependencies: lowest, framework: '', experimental: false } + - { os: windows-latest, php: '8.4', dependencies: stable, framework: '', experimental: true } + - { os: ubuntu-latest, php: '8.6', dependencies: stable, framework: '', experimental: true } + - { os: ubuntu-latest, php: '8.5', dependencies: stable, framework: '14.x-dev as 13.999.0', experimental: true } + + name: PHP ${{ matrix.php }} - ${{ matrix.dependencies }}${{ matrix.framework && ' - Laravel 14.x-dev' || '' }}${{ matrix.os == 'windows-latest' && ' - Windows' || '' }} steps: - name: Checkout code @@ -38,21 +44,23 @@ jobs: uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, json + extensions: dom, curl, libxml, mbstring, zip, json, pdo_sqlite coverage: none + - name: Require the Laravel version + if: matrix.framework != '' + run: composer require "laravel/framework:${{ matrix.framework }}" --no-interaction --no-update + - name: Install dependencies - run: | - composer require "laravel/framework:${{ matrix.laravel }}" --no-interaction --no-update - composer update --prefer-dist --no-interaction + run: composer update --prefer-dist --no-interaction ${{ matrix.dependencies == 'lowest' && '--prefer-lowest --prefer-stable' || '' }} - name: Run tests - run: composer test + run: vendor/bin/pest --parallel --order-by=random - static-analysis: + coverage: runs-on: ubuntu-latest - name: Static Analysis + name: Coverage steps: - name: Checkout code @@ -63,23 +71,23 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: - php-version: '8.3' - extensions: dom, curl, libxml, mbstring, zip, json - coverage: none + php-version: '8.5' + extensions: dom, curl, libxml, mbstring, zip, json, pdo_sqlite + coverage: pcov - name: Install dependencies run: composer update --prefer-dist --no-interaction - - name: Run PHPStan - run: composer analyse + - name: Check the coverage + run: vendor/bin/pest --coverage --min=95 - - name: Audit dependencies - run: composer audit + - name: Check the mutation score + run: vendor/bin/pest --mutate --everything --covered-only --parallel --min=85 - code-style: + static-analysis: runs-on: ubuntu-latest - name: Code Style + name: Static Analysis steps: - name: Checkout code @@ -97,5 +105,11 @@ jobs: - name: Install dependencies run: composer update --prefer-dist --no-interaction + - name: Run PHPStan + run: composer analyse + - name: Check code style - run: ./vendor/bin/php-cs-fixer fix --dry-run --diff --allow-risky=yes + run: composer lint + + - name: Audit dependencies + run: composer audit diff --git a/composer.json b/composer.json index 7ca25b2..420d75a 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "laraneat/modules", - "description": "Laraneat modules.", + "description": "Modular monolith infrastructure for Laravel: modules as Composer path packages, wired by conventions.", "homepage": "https://github.com/laraneat/modules/", "support": { "issues": "https://github.com/laraneat/modules/issues", @@ -13,74 +13,65 @@ } ], "keywords": [ - "laraneat", - "modules", - "core", - "laraneat modules", - "laraneat-modules", "laravel", - "Porto", - "Porto SAP" + "modules", + "modular monolith", + "laraneat" ], "license": "MIT", "require": { - "php": "^8.1", + "php": "^8.3", "ext-json": "*", - "composer/composer": "^2.1", - "laravel/framework": "^10.0 || ^11.0 || ^12.0 || ^13.0" + "laravel/framework": "^13.12" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.48", - "larastan/larastan": "^2.0 || ^3.0", - "mockery/mockery": "^1.0", - "orchestra/testbench": "^8.0 || ^9.0 || ^10.0 || ^11.0", - "pestphp/pest": "^2.31 || ^3.0 || ^4.0 || ^5.0", - "pestphp/pest-plugin-laravel": "^2.0 || ^3.0 || ^4.0 || ^5.0", - "phpstan/phpstan": "^1.10.58 || ^2.0", + "larastan/larastan": "^3.0", + "laravel/octane": "^2.0", + "laravel/pint": "^1.0", + "lorisleiva/laravel-actions": "^2.0", + "orchestra/testbench": "^11.0", + "pestphp/pest": "^4.0 || ^5.0", + "phpstan/phpstan": "^2.0", "roave/security-advisories": "dev-latest", - "spatie/pest-plugin-snapshots": "^2.1" + "spatie/laravel-data": "^4.0" }, "autoload": { "psr-4": { - "Laraneat\\Modules\\": "src" + "Laraneat\\Modules\\": "src/" } }, "autoload-dev": { "psr-4": { - "Laraneat\\Modules\\Tests\\": "tests", - "App\\": "tests/fixtures/laravel/app", - "Modules\\": "tests/fixtures/laravel/modules" + "Laraneat\\Modules\\Tests\\": "tests/", + "Modules\\Blog\\": "tests/Fixtures/app/modules/blog/src/", + "Modules\\Blog\\Database\\Factories\\": "tests/Fixtures/app/modules/blog/database/factories/", + "Modules\\Blog\\Database\\Seeders\\": "tests/Fixtures/app/modules/blog/database/seeders/", + "Modules\\ShopOrder\\": "tests/Fixtures/app/modules/shop-order/src/", + "Modules\\ShopOrder\\Database\\Seeders\\": "tests/Fixtures/app/modules/shop-order/database/seeders/" } }, - "suggest": { - "lorisleiva/laravel-actions": "Laravel components that take care of one specific task", - "jackardios/laravel-query-wizard": "Easily build Eloquent queries from API requests", - "spatie/laravel-data": "Powerful data objects for Laravel" - }, "extra": { "laravel": { "providers": [ - "Laraneat\\Modules\\Providers\\ComposerServiceProvider", - "Laraneat\\Modules\\Providers\\ConsoleServiceProvider", - "Laraneat\\Modules\\Providers\\ModulesRepositoryServiceProvider", - "Laraneat\\Modules\\Providers\\ModulesServiceProvider" + "Laraneat\\Modules\\ModulesServiceProvider" ], "aliases": { - "Modules": "Laraneat\\Modules\\Support\\Facades\\Modules" + "Modules": "Laraneat\\Modules\\Facades\\Modules" } } }, "scripts": { - "analyse" : "./vendor/bin/phpstan analyse --memory-limit=256M", - "test" : "./vendor/bin/pest --no-coverage", - "test-coverage" : "./vendor/bin/pest --coverage-html coverage", - "update-snapshots": "./vendor/bin/pest --no-coverage --update-snapshots", - "format": "./vendor/bin/php-cs-fixer fix --allow-risky=yes", + "test": "pest --parallel --order-by=random", + "test:coverage": "pest --coverage --min=95", + "test:mutate": "pest --mutate --everything --covered-only --parallel --min=85", + "analyse": "phpstan analyse --memory-limit=1G", + "format": "pint", + "lint": "pint --test", "post-autoload-dump": [ "@php ./vendor/bin/testbench package:discover --ansi" ] }, - "config" : { + "config": { "sort-packages": true, "allow-plugins": { "pestphp/pest-plugin": true diff --git a/phpstan.neon.dist b/phpstan.neon.dist index db492d2..500bd5a 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,22 +1,10 @@ includes: - - phpstan-baseline.neon - vendor/larastan/larastan/extension.neon parameters: - level: 5 + level: max paths: - src + - config tmpDir: build/phpstan - reportUnmatchedIgnoredErrors: true checkOctaneCompatibility: true - checkModelProperties: true - ignoreErrors: - # These traits are used in stubs for generated code - - identifier: trait.unused - path: src/Support/Concerns/CanLoadRoutesFromDirectory.php - - identifier: trait.unused - path: src/Support/Concerns/CanRunModuleSeeders.php - - identifier: trait.unused - path: src/Support/Concerns/InteractsWithTestUser.php - - identifier: trait.unused - path: src/Support/Concerns/WithJsonResponseHelpers.php diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..6332a36 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,33 @@ + + + + + ./tests + + + + + ./src + + + + + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..9a25e3c --- /dev/null +++ b/pint.json @@ -0,0 +1,10 @@ +{ + "preset": "laravel", + "rules": { + "declare_strict_types": true, + "final_class": true + }, + "exclude": [ + "tests/Fixtures" + ] +} From 9a6409f0c13b21ddfc26c246db5014a17232448e Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 09:36:46 +0300 Subject: [PATCH 03/38] Add the module manifest Modules are found once in the modules directory and described by a manifest: the package, the namespace and every file the package loads. The manifest is cached like the config and built once per process without the cache. The Modules facade lists modules and their seeders. --- src/Exceptions/ComposerFailed.php | 16 ++ src/Exceptions/InvalidConfiguration.php | 15 ++ src/Exceptions/InvalidModule.php | 16 ++ src/Exceptions/InvalidName.php | 15 ++ src/Exceptions/InvalidTemplate.php | 15 ++ src/Exceptions/ModuleNotFound.php | 15 ++ src/Exceptions/ModulesException.php | 12 + src/Facades/Modules.php | 24 ++ src/Manifest/ManifestBuilder.php | 292 ++++++++++++++++++++++++ src/Manifest/ManifestCache.php | 77 +++++++ src/Module.php | 23 ++ src/ModuleRepository.php | 192 ++++++++++++++++ src/Scaffold/Names.php | 86 +++++++ 13 files changed, 798 insertions(+) create mode 100644 src/Exceptions/ComposerFailed.php create mode 100644 src/Exceptions/InvalidConfiguration.php create mode 100644 src/Exceptions/InvalidModule.php create mode 100644 src/Exceptions/InvalidName.php create mode 100644 src/Exceptions/InvalidTemplate.php create mode 100644 src/Exceptions/ModuleNotFound.php create mode 100644 src/Exceptions/ModulesException.php create mode 100644 src/Facades/Modules.php create mode 100644 src/Manifest/ManifestBuilder.php create mode 100644 src/Manifest/ManifestCache.php create mode 100644 src/Module.php create mode 100644 src/ModuleRepository.php create mode 100644 src/Scaffold/Names.php diff --git a/src/Exceptions/ComposerFailed.php b/src/Exceptions/ComposerFailed.php new file mode 100644 index 0000000..ec232e4 --- /dev/null +++ b/src/Exceptions/ComposerFailed.php @@ -0,0 +1,16 @@ + all() + * @method static \Laraneat\Modules\Module|null find(string $name) + * @method static \Laraneat\Modules\Module get(string $name) + * @method static list> seeders(string ...$directories) + * + * @see ModuleRepository + */ +final class Modules extends Facade +{ + protected static function getFacadeAccessor(): string + { + return ModuleRepository::class; + } +} diff --git a/src/Manifest/ManifestBuilder.php b/src/Manifest/ManifestBuilder.php new file mode 100644 index 0000000..9864b61 --- /dev/null +++ b/src/Manifest/ManifestBuilder.php @@ -0,0 +1,292 @@ +, + * lang: bool, + * json: bool, + * views: bool, + * migrations: bool, + * routes: array>>, + * seeders: array>, + * commands: list, + * } + * @phpstan-type Manifest array + */ +final readonly class ManifestBuilder +{ + private string $modulesPath; + + private string $commandsNamespace; + + /** + * @param array $routes The route directory of every route group. + */ + public function __construct( + string $modulesPath, + private array $routes, + string $commandsNamespace, + ) { + $this->modulesPath = rtrim(str_replace('\\', '/', $modulesPath), '/'); + $this->commandsNamespace = trim($commandsNamespace, '\\'); + } + + /** + * Identifies the settings the manifest is built with. + */ + public function key(): string + { + return hash('xxh128', serialize([$this->modulesPath, $this->routes, $this->commandsNamespace])); + } + + public function modulesPath(): string + { + return $this->modulesPath; + } + + /** + * @return Manifest + */ + public function build(): array + { + $manifest = []; + $packages = []; + + foreach ($this->scan($this->modulesPath)[1] as $name) { + $path = $this->modulesPath.'/'.$name; + + if (! is_file($path.'/composer.json')) { + continue; + } + + $module = $this->module($path); + + if (isset($packages[$module['package']])) { + throw InvalidModule::at($path, "the package name [{$module['package']}] is already used by the [{$packages[$module['package']]}] module."); + } + + $packages[$module['package']] = $name; + $manifest[$name] = $module; + } + + return $manifest; + } + + /** + * @return ModuleManifest + */ + private function module(string $path): array + { + $composer = $this->composerJson($path); + + $package = $composer['name'] ?? null; + + if (! is_string($package) || ! Names::isPackage($package)) { + throw InvalidModule::at($path, 'composer.json must have a valid "name".'); + } + + $autoload = is_array($composer['autoload'] ?? null) ? ($composer['autoload']['psr-4'] ?? null) : null; + $namespace = is_array($autoload) ? array_key_first($autoload) : null; + $source = $namespace === null ? null : (is_array($autoload[$namespace]) ? ($autoload[$namespace][0] ?? null) : $autoload[$namespace]); + + if (! is_string($namespace) || ! Names::isNamespace(rtrim($namespace, '\\')) || ! str_ends_with($namespace, '\\') || ! is_string($source)) { + throw InvalidModule::at($path, 'the first "autoload.psr-4" entry of composer.json must map the root namespace of the module (e.g. "Modules\\\\Blog\\\\": "src/").'); + } + + $namespace = rtrim($namespace, '\\'); + $source = trim((string) preg_replace('{^\./}', '', str_replace('\\', '/', $source)), '/'); + $lang = is_dir($path.'/lang'); + $json = $lang && preg_grep('/\.json$/D', scandir($path.'/lang') ?: []) !== []; + + return [ + 'package' => $package, + 'namespace' => $namespace, + 'path' => $path, + 'src' => $source, + 'config' => array_map(static fn (string $file): string => substr($file, 0, -4), $this->scan($path.'/config')[0]), + 'lang' => $lang, + 'json' => $json, + 'views' => is_dir($path.'/resources/views'), + 'migrations' => is_dir($path.'/database/migrations'), + 'routes' => $this->routes($path), + 'seeders' => $this->seeders($path, $namespace), + 'commands' => $this->commands($path, $source, $namespace), + ]; + } + + /** + * @return array + */ + private function composerJson(string $path): array + { + try { + $composer = json_decode((string) file_get_contents($path.'/composer.json'), true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $exception) { + throw InvalidModule::at($path, 'composer.json is not valid JSON: '.$exception->getMessage(), $exception); + } + + if (! is_array($composer)) { + throw InvalidModule::at($path, 'composer.json must contain an object.'); + } + + return $composer; + } + + /** + * Route files by route group, then by the prefix of their directory: files of a directory + * come before its subdirectories, everything in alphabetical order. + * + * @return array>> + */ + private function routes(string $path): array + { + $routes = []; + + foreach ($this->routes as $group => $directory) { + $directory = trim(str_replace('\\', '/', $directory), '/'); + + if (is_dir($path.'/'.$directory)) { + $routes[$group] = $this->routeFiles($path, $directory, ''); + } + } + + return $routes; + } + + /** + * @return array> + */ + private function routeFiles(string $path, string $directory, string $prefix): array + { + [$files, $directories] = $this->scan($path.'/'.$directory); + $routes = []; + + foreach ($files as $file) { + $routes[$prefix][] = $directory.'/'.$file; + } + + foreach ($directories as $subdirectory) { + $routes += $this->routeFiles($path, $directory.'/'.$subdirectory, ltrim($prefix.'/'.$subdirectory, '/')); + } + + return $routes; + } + + /** + * Seeder classes of "database/seeders" (key "") and of its direct subdirectories. + * + * @return array> + */ + private function seeders(string $path, string $namespace): array + { + $directory = $path.'/database/seeders'; + $namespace .= '\\Database\\Seeders\\'; + [$files, $directories] = $this->scan($directory); + $seeders = []; + + foreach ($this->classes($files) as $class) { + $seeders[''][] = $namespace.$class; + } + + foreach (array_filter($directories, $this->isIdentifier(...)) as $subdirectory) { + foreach ($this->classes($this->scan($directory.'/'.$subdirectory)[0]) as $class) { + $seeders[$subdirectory][] = $namespace.$subdirectory.'\\'.$class; + } + } + + return $seeders; + } + + /** + * Classes of the "make:command" namespace, including its subdirectories. + * + * @return list + */ + private function commands(string $path, string $source, string $namespace): array + { + $directory = $path.($source === '' ? '' : '/'.$source).'/'.str_replace('\\', '/', $this->commandsNamespace); + + return $this->classesIn($directory, $namespace.'\\'.$this->commandsNamespace.'\\'); + } + + /** + * @return list + */ + private function classesIn(string $directory, string $namespace): array + { + [$files, $directories] = $this->scan($directory); + $classes = array_map(static fn (string $class): string => $namespace.$class, $this->classes($files)); + + foreach (array_filter($directories, $this->isIdentifier(...)) as $subdirectory) { + array_push($classes, ...$this->classesIn($directory.'/'.$subdirectory, $namespace.$subdirectory.'\\')); + } + + return $classes; + } + + /** + * Class names of PHP files. + * + * @param list $files + * @return list + */ + private function classes(array $files): array + { + return array_values(array_filter( + array_map(static fn (string $file): string => substr($file, 0, -4), $files), + $this->isIdentifier(...), + )); + } + + /** + * PHP files and subdirectories of a directory, sorted, without hidden entries. + * + * @return array{list, list} + */ + private function scan(string $directory): array + { + $entries = is_dir($directory) ? (scandir($directory) ?: []) : []; + $files = []; + $directories = []; + + sort($entries, SORT_STRING); + + foreach ($entries as $entry) { + if ($entry[0] === '.') { + continue; + } + + if (is_dir($directory.'/'.$entry)) { + $directories[] = $entry; + } elseif (str_ends_with($entry, '.php')) { + $files[] = $entry; + } + } + + return [$files, $directories]; + } + + private function isIdentifier(string $name): bool + { + return preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/D', $name) === 1; + } +} diff --git a/src/Manifest/ManifestCache.php b/src/Manifest/ManifestCache.php new file mode 100644 index 0000000..a5ef85d --- /dev/null +++ b/src/Manifest/ManifestCache.php @@ -0,0 +1,77 @@ +basePath = rtrim(str_replace('\\', '/', $basePath), '/'); + } + + public function path(): string + { + return $this->path; + } + + public function exists(): bool + { + return is_file($this->path); + } + + /** + * @return Manifest + */ + public function read(): array + { + /** @var Manifest $manifest */ + $manifest = require $this->path; + + return array_map(fn (array $module): array => [...$module, 'path' => $this->absolute($module['path'])], $manifest); + } + + /** + * @param Manifest $manifest + */ + public function write(array $manifest): void + { + $manifest = array_map(fn (array $module): array => [...$module, 'path' => $this->relative($module['path'])], $manifest); + + $this->files->ensureDirectoryExists(dirname($this->path)); + $this->files->replace($this->path, 'exists()) { + $this->files->delete($this->path); + } + } + + private function relative(string $path): string + { + return str_starts_with($path, $this->basePath.'/') ? substr($path, strlen($this->basePath) + 1) : $path; + } + + private function absolute(string $path): string + { + return preg_match('{^(/|[A-Za-z]:[/\\\\])}', $path) === 1 ? $path : $this->basePath.'/'.$path; + } +} diff --git a/src/Module.php b/src/Module.php new file mode 100644 index 0000000..1e63016 --- /dev/null +++ b/src/Module.php @@ -0,0 +1,23 @@ + + */ + private static array $manifests = []; + + /** + * @var array|null + */ + private ?array $modules = null; + + public function __construct( + private readonly ManifestBuilder $builder, + private readonly ManifestCache $cache, + ) {} + + /** + * @return array + */ + public function all(): array + { + return $this->modules ??= array_map(static fn (array $module): Module => new Module( + name: basename($module['path']), + package: $module['package'], + namespace: $module['namespace'], + path: $module['path'], + sourcePath: $module['src'] === '' ? $module['path'] : $module['path'].'/'.$module['src'], + ), $this->manifest()); + } + + public function find(string $name): ?Module + { + return $this->all()[$name] ?? null; + } + + /** + * @throws ModuleNotFound + */ + public function get(string $name): Module + { + return $this->find($name) ?? throw ModuleNotFound::named($name); + } + + /** + * Seeders of all modules, from "database/seeders" and the given subdirectories of it + * ("" is "database/seeders" itself). They run in the order of their "_N" class name + * suffix; seeders without a suffix run last. Equal suffixes run by class name. + * + * @return list> + */ + public function seeders(string ...$directories): array + { + $directories = $directories === [] ? [''] : array_unique(array_map(static fn (string $directory): string => trim($directory, '/\\'), $directories)); + $seeders = []; + + foreach ($this->manifest() as $module) { + foreach ($directories as $directory) { + foreach ($module['seeders'][$directory] ?? [] as $class) { + if (is_subclass_of($class, Seeder::class) && ! (new ReflectionClass($class))->isAbstract()) { + $seeders[] = $class; + } + } + } + } + + usort($seeders, static fn (string $a, string $b): int => [self::order($a), $a] <=> [self::order($b), $b]); + + return $seeders; + } + + /** + * @internal + * + * @return Manifest + */ + public function manifest(): array + { + return self::$manifests[$this->key()] ??= $this->cache->exists() ? $this->cache->read() : $this->builder->build(); + } + + /** + * @internal + */ + public function modulesPath(): string + { + return $this->builder->modulesPath(); + } + + /** + * @internal + */ + public function cachePath(): string + { + return $this->cache->path(); + } + + /** + * @internal + */ + public function isCached(): bool + { + return $this->cache->exists(); + } + + /** + * @internal + */ + public function cache(): void + { + $this->cache->write($this->builder->build()); + $this->forget(); + } + + /** + * @internal + */ + public function clearCache(): void + { + $this->cache->delete(); + $this->forget(); + } + + /** + * Pick up created or deleted modules; the cache file is rebuilt only if it exists. + * + * @internal + */ + public function refresh(): void + { + $this->forget(); + + if ($this->cache->exists()) { + $this->cache(); + } + } + + /** + * Whether the cache file differs from the modules on disk. + * + * @internal + */ + public function isCacheStale(): bool + { + return $this->cache->exists() && $this->cache->read() !== $this->builder->build(); + } + + /** + * @internal + */ + public static function flushState(): void + { + self::$manifests = []; + } + + private function forget(): void + { + unset(self::$manifests[$this->key()]); + $this->modules = null; + } + + private function key(): string + { + return $this->cache->path()."\0".$this->builder->key(); + } + + private static function order(string $class): int + { + $basename = class_basename($class); + $suffix = substr((string) strrchr($basename, '_'), 1); + + return ctype_digit($suffix) ? (int) $suffix : PHP_INT_MAX; + } +} diff --git a/src/Scaffold/Names.php b/src/Scaffold/Names.php new file mode 100644 index 0000000..f1754be --- /dev/null +++ b/src/Scaffold/Names.php @@ -0,0 +1,86 @@ + Date: Wed, 23 Sep 2026 09:36:46 +0300 Subject: [PATCH 04/38] Load module resources by convention Config, translations, views, Blade components, migrations and route groups are loaded for every module from the manifest. Commands, factory resolution, Tinker aliases and the Octane watcher are console-only. --- src/Registrars/ConsoleRegistrar.php | 163 +++++++++++++++++++++++++++ src/Registrars/ResourceRegistrar.php | 118 +++++++++++++++++++ src/Registrars/RouteRegistrar.php | 53 +++++++++ 3 files changed, 334 insertions(+) create mode 100644 src/Registrars/ConsoleRegistrar.php create mode 100644 src/Registrars/ResourceRegistrar.php create mode 100644 src/Registrars/RouteRegistrar.php diff --git a/src/Registrars/ConsoleRegistrar.php b/src/Registrars/ConsoleRegistrar.php new file mode 100644 index 0000000..eeeb04b --- /dev/null +++ b/src/Registrars/ConsoleRegistrar.php @@ -0,0 +1,163 @@ +registerCommands(); + + if ($this->manifest !== []) { + $this->registerFactoryResolvers(); + $this->registerTinkerAliases(); + } + + $this->watchWithOctane(); + } + + private function registerCommands(): void + { + $commands = array_merge(...array_column($this->manifest, 'commands')); + + if ($commands === []) { + return; + } + + Artisan::starting(static function (Artisan $artisan) use ($commands): void { + $artisan->resolveCommands(array_values(array_filter( + $commands, + static fn (string $class): bool => is_subclass_of($class, Command::class) && ! (new ReflectionClass($class))->isAbstract(), + ))); + }); + } + + /** + * "Modules\Blog\Models\Post" uses "Modules\Blog\Database\Factories\PostFactory" and back, + * the way Laravel pairs "App\Models\Post" with "Database\Factories\PostFactory". + * Classes outside the modules keep the default Laravel behavior. + */ + private function registerFactoryResolvers(): void + { + $namespaces = array_map(static fn (array $module): string => $module['namespace'].'\\', array_values($this->manifest)); + + usort($namespaces, static fn (string $a, string $b): int => strlen($b) <=> strlen($a)); + + // @phpstan-ignore argument.type (like the default resolver, it returns a class name that may not exist yet) + Factory::guessFactoryNamesUsing(static function (string $model) use ($namespaces): string { + [$root, $factories] = self::roots($model, $namespaces, false); + + $name = str_starts_with($model, $root.'Models\\') + ? substr($model, strlen($root.'Models\\')) + : Str::after($model, $root); + + return $factories.$name.'Factory'; + }); + + // @phpstan-ignore argument.type (like the default resolver, it returns a class name that may not exist) + Factory::guessModelNamesUsing(static function (Factory $factory) use ($namespaces): string { + [$root, $factories] = self::roots($factory::class, $namespaces, true); + + $name = Str::replaceLast('Factory', '', Str::replaceFirst($factories, '', $factory::class)); + + return class_exists($root.'Models\\'.$name) + ? $root.'Models\\'.$name + : $root.Str::replaceLast('Factory', '', class_basename($factory)); + }); + } + + /** + * The root namespace and the factories namespace the class belongs to. + * + * @param list $namespaces + * @return array{string, string} + */ + private static function roots(string $class, array $namespaces, bool $isFactory): array + { + foreach ($namespaces as $namespace) { + if (str_starts_with($class, $isFactory ? $namespace.'Database\\Factories\\' : $namespace)) { + return [$namespace, $namespace.'Database\\Factories\\']; + } + } + + try { + $root = Container::getInstance()->make(ApplicationContract::class)->getNamespace(); + } catch (Throwable) { + $root = 'App\\'; + } + + return [$root, Factory::$namespace]; + } + + /** + * Module classes are installed under "vendor", which Tinker does not alias by default. + */ + private function registerTinkerAliases(): void + { + $config = $this->config(); + $aliases = array_filter((array) $config->get('tinker.alias', []), is_string(...)); + + foreach ($this->manifest as $module) { + $aliases[] = $module['namespace'].'\\'; + } + + $config->set('tinker.alias', array_values(array_unique($aliases))); + } + + /** + * Let "octane:start --watch" reload the workers when module files change. + * Octane resolves watched paths against the base path, so only a path inside it can be added. + */ + private function watchWithOctane(): void + { + $config = $this->config(); + $watch = $config->get('octane.watch'); + $basePath = rtrim(str_replace('\\', '/', $this->app->basePath()), '/').'/'; + + if (! is_array($watch) || ! str_starts_with($this->modulesPath.'/', $basePath)) { + return; + } + + $path = substr($this->modulesPath, strlen($basePath)); + + if ($path !== '' && ! in_array($path, $watch, true)) { + $config->set('octane.watch', [...$watch, $path]); + } + } + + private function config(): Repository + { + return $this->app->make('config'); + } +} diff --git a/src/Registrars/ResourceRegistrar.php b/src/Registrars/ResourceRegistrar.php new file mode 100644 index 0000000..3eb2a70 --- /dev/null +++ b/src/Registrars/ResourceRegistrar.php @@ -0,0 +1,118 @@ +" override lookup of loadViewsFrom(): modules belong to the + * application. JSON translation paths are added only for modules that have them. + * + * @internal + * + * @phpstan-import-type Manifest from ManifestBuilder + */ +final readonly class ResourceRegistrar +{ + /** + * @param Manifest $manifest + */ + public function __construct( + private Application $app, + private array $manifest, + ) {} + + /** + * Merge the config files of the modules; the application config wins. Runs in register(), + * so the config is complete before any provider boots. + */ + public function registerConfig(): void + { + if ($this->app instanceof CachesConfiguration && $this->app->configurationIsCached()) { + return; + } + + $config = $this->app->make('config'); + + foreach ($this->manifest as $module) { + foreach ($module['config'] as $key) { + $values = require $module['path'].'/config/'.$key.'.php'; + + if (! is_array($values)) { + throw InvalidModule::at($module['path'], "config/{$key}.php must return an array."); + } + + $config->set($key, array_merge($values, (array) $config->get($key, []))); + } + } + } + + public function boot(): void + { + $lang = array_filter($this->manifest, static fn (array $module): bool => $module['lang']); + $views = array_filter($this->manifest, static fn (array $module): bool => $module['views']); + + if ($lang !== []) { + $this->afterResolving('translator', static function (Translator $translator) use ($lang): void { + foreach ($lang as $name => $module) { + $translator->addNamespace($name, $module['path'].'/lang'); + + if ($module['json']) { + $translator->addJsonPath($module['path'].'/lang'); + } + } + }); + } + + if ($views !== []) { + $this->afterResolving('view', static function (ViewFactory $view) use ($views): void { + foreach ($views as $name => $module) { + $view->addNamespace($name, $module['path'].'/resources/views'); + } + }); + } + + if ($this->manifest !== []) { + // renders Modules\Blog\View\Components\Alert, or the "blog::components.alert" view. + $this->afterResolving('blade.compiler', function (BladeCompiler $blade): void { + foreach ($this->manifest as $name => $module) { + $blade->componentNamespace($module['namespace'].'\\View\\Components', $name); + } + }); + } + + if ($this->app->runningInConsole()) { + $migrations = array_filter($this->manifest, static fn (array $module): bool => $module['migrations']); + + if ($migrations !== []) { + $this->afterResolving('migrator', static function (Migrator $migrator) use ($migrations): void { + foreach ($migrations as $module) { + $migrator->path($module['path'].'/database/migrations'); + } + }); + } + } + } + + private function afterResolving(string $abstract, Closure $callback): void + { + $this->app->afterResolving($abstract, $callback); + + if ($this->app->resolved($abstract)) { + $callback($this->app->make($abstract)); + } + } +} diff --git a/src/Registrars/RouteRegistrar.php b/src/Registrars/RouteRegistrar.php new file mode 100644 index 0000000..3d29cc9 --- /dev/null +++ b/src/Registrars/RouteRegistrar.php @@ -0,0 +1,53 @@ + $groups The "routes" config. + * @param Manifest $manifest + */ + public function __construct( + private Router $router, + private array $groups, + private array $manifest, + ) {} + + public function register(): void + { + foreach ($this->groups as $group => $attributes) { + if (! is_array($attributes)) { + continue; + } + + unset($attributes['path']); + $prefix = is_string($attributes['prefix'] ?? null) ? trim($attributes['prefix'], '/') : ''; + + foreach ($this->manifest as $module) { + foreach ($module['routes'][$group] ?? [] as $directory => $files) { + $directoryAttributes = $directory === '' + ? $attributes + : ['prefix' => ltrim($prefix.'/'.$directory, '/')] + $attributes; + + foreach ($files as $file) { + $this->router->group($directoryAttributes, $module['path'].'/'.$file); + } + } + } + } + } +} From 21f316d38f64b49a465fc2959e2657245184ed0e Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 09:36:46 +0300 Subject: [PATCH 05/38] Add --module to every generator Every GeneratorCommand, make:migration and the table migration generators get a --module option. While the generator runs, the application paths and namespace point at the module. A few generators are adjusted: factories, seeders and tests go to their module directories, views are named after the module, and a module provider is registered in the composer.json of the module. --- src/Generators/ComponentMakeCommand.php | 15 +++ src/Generators/FactoryMakeCommand.php | 36 ++++++ src/Generators/MailMakeCommand.php | 15 +++ src/Generators/ModelMakeCommand.php | 65 +++++++++++ src/Generators/ModuleContext.php | 71 ++++++++++++ src/Generators/ModuleOption.php | 121 +++++++++++++++++++++ src/Generators/NamespacesViews.php | 57 ++++++++++ src/Generators/NotificationMakeCommand.php | 15 +++ src/Generators/ProviderMakeCommand.php | 46 ++++++++ src/Generators/ResolvesModule.php | 21 ++++ src/Generators/SeederMakeCommand.php | 26 +++++ src/Generators/TestMakeCommand.php | 39 +++++++ src/Generators/ViewMakeCommand.php | 49 +++++++++ 13 files changed, 576 insertions(+) create mode 100644 src/Generators/ComponentMakeCommand.php create mode 100644 src/Generators/FactoryMakeCommand.php create mode 100644 src/Generators/MailMakeCommand.php create mode 100644 src/Generators/ModelMakeCommand.php create mode 100644 src/Generators/ModuleContext.php create mode 100644 src/Generators/ModuleOption.php create mode 100644 src/Generators/NamespacesViews.php create mode 100644 src/Generators/NotificationMakeCommand.php create mode 100644 src/Generators/ProviderMakeCommand.php create mode 100644 src/Generators/ResolvesModule.php create mode 100644 src/Generators/SeederMakeCommand.php create mode 100644 src/Generators/TestMakeCommand.php create mode 100644 src/Generators/ViewMakeCommand.php diff --git a/src/Generators/ComponentMakeCommand.php b/src/Generators/ComponentMakeCommand.php new file mode 100644 index 0000000..9038e1f --- /dev/null +++ b/src/Generators/ComponentMakeCommand.php @@ -0,0 +1,15 @@ +currentModule(); + + if ($module === null) { + return $class; + } + + return (string) preg_replace_callback( + '/^namespace Database\\\\Factories(?=[\\\;])/m', + static fn (): string => 'namespace '.$module->namespace.'\\Database\\Factories', + $class, + 1, + ); + } +} diff --git a/src/Generators/MailMakeCommand.php b/src/Generators/MailMakeCommand.php new file mode 100644 index 0000000..9c60886 --- /dev/null +++ b/src/Generators/MailMakeCommand.php @@ -0,0 +1,15 @@ +currentModule(); + + if ($module === null) { + parent::createFactory(); + + return; + } + + $this->call('make:factory', [ + 'name' => Str::after($this->factoryClass(), $module->namespace.'\\Database\\Factories\\'), + '--model' => $this->qualifyClass($this->getNameInput()), + ]); + } + + /** + * @return array + */ + #[Override] + protected function buildFactoryReplacements() + { + $replacements = parent::buildFactoryReplacements(); + + if ($this->currentModule() !== null && isset($replacements['{{ factory }}'])) { + $factory = $this->factoryClass(); + + $replacements['{{ factory }}'] = (string) preg_replace_callback( + '/HasFactory<[^>]*>/', + static fn (): string => 'HasFactory<\\'.$factory.'>', + $replacements['{{ factory }}'], + ); + } + + return $replacements; + } + + private function factoryClass(): string + { + // @phpstan-ignore argument.type, argument.templateType (the model is being generated, it does not exist yet) + return Factory::resolveFactoryName($this->qualifyClass($this->getNameInput())); + } +} diff --git a/src/Generators/ModuleContext.php b/src/Generators/ModuleContext.php new file mode 100644 index 0000000..7fef252 --- /dev/null +++ b/src/Generators/ModuleContext.php @@ -0,0 +1,71 @@ +module; + } + + /** + * @template TResult + * + * @param Closure(): TResult $callback + * @return TResult + */ + public function run(Module $module, Closure $callback): mixed + { + $config = $this->app->make('config'); + $namespace = new ReflectionProperty(Application::class, 'namespace'); + + $previous = [ + $this->module, + $this->app->path(), + $this->app->databasePath(), + $this->app->configPath(), + $namespace->getValue($this->app), + $config->get('view.paths'), + ]; + + $this->module = $module; + $this->app->useAppPath($module->sourcePath); + $this->app->useDatabasePath($module->path.'/database'); + $this->app->useConfigPath($module->path.'/config'); + $namespace->setValue($this->app, $module->namespace.'\\'); + $config->set('view.paths', [$module->path.'/resources/views']); + + try { + return $callback(); + } finally { + [$this->module, $appPath, $databasePath, $configPath, $previousNamespace, $viewPaths] = $previous; + + $this->app->useAppPath($appPath); + $this->app->useDatabasePath($databasePath); + $this->app->useConfigPath($configPath); + $namespace->setValue($this->app, $previousNamespace); + $config->set('view.paths', $viewPaths); + } + } +} diff --git a/src/Generators/ModuleOption.php b/src/Generators/ModuleOption.php new file mode 100644 index 0000000..0e6ccbb --- /dev/null +++ b/src/Generators/ModuleOption.php @@ -0,0 +1,121 @@ + ComponentMakeCommand::class, + BaseFactoryMakeCommand::class => FactoryMakeCommand::class, + BaseMailMakeCommand::class => MailMakeCommand::class, + BaseModelMakeCommand::class => ModelMakeCommand::class, + BaseNotificationMakeCommand::class => NotificationMakeCommand::class, + BaseProviderMakeCommand::class => ProviderMakeCommand::class, + BaseSeederMakeCommand::class => SeederMakeCommand::class, + BaseTestMakeCommand::class => TestMakeCommand::class, + BaseViewMakeCommand::class => ViewMakeCommand::class, + ]; + + public static function register(Container $app): void + { + $app->singleton(ModuleContext::class); + + $app->resolving(GeneratorCommand::class, self::addTo(...)); + $app->resolving(MigrateMakeCommand::class, self::addTo(...)); + $app->resolving(MigrationGeneratorCommand::class, self::addTo(...)); + + // Generators that hard-code a namespace, a destination or a view name of the application. + foreach (self::GENERATORS as $base => $generator) { + $app->extend($base, static fn (Command $command, Container $app): Command => new $generator($app->make(Filesystem::class))); + } + } + + /** + * The option is added when the command is resolved, and the command code is replaced by + * a closure bound to the command, so its protected execute() still does the work. + * Symfony binds the input before the code runs, which is why this can not happen earlier. + */ + public static function addTo(Command $command): void + { + if ($command->getDefinition()->hasOption('module')) { + return; + } + + $command->addOption('module', null, InputOption::VALUE_REQUIRED, 'Generate the file inside the given module'); + + $command->setCode(Closure::bind(function (InputInterface $input, OutputInterface $output): int { + /** @var Command $this */ + $name = $input->getOption('module'); + + if (! is_string($name)) { + return $this->execute($input, $output); + } + + $laravel = $this->getLaravel(); + $module = $laravel->make(ModuleRepository::class)->find($name); + + if ($module === null) { + $this->components->error("Module [{$name}] not found."); + + return Command::FAILURE; + } + + ModuleOption::qualifyName($this, $input, $module, Config::array('modules.generators', [])); + + return $laravel->make(ModuleContext::class)->run($module, fn (): int => $this->execute($input, $output)); + }, $command, $command::class)); + } + + /** + * Apply the "generators" config: "make:controller Post" becomes "Modules\Blog\\Post". + * A fully qualified name is left as is by the generator. + * + * @param array $generators + */ + public static function qualifyName(Command $command, InputInterface $input, Module $module, array $generators): void + { + $namespace = $generators[$command->getName()] ?? null; + + if (! is_string($namespace) || ! $input->hasArgument('name') || ! is_string($class = $input->getArgument('name'))) { + return; + } + + $class = trim(str_replace('/', '\\', $class), '\\'); + + if ($class !== '' && ! str_starts_with($class, $module->namespace.'\\')) { + $input->setArgument('name', $module->namespace.'\\'.trim($namespace, '\\').'\\'.$class); + } + } +} diff --git a/src/Generators/NamespacesViews.php b/src/Generators/NamespacesViews.php new file mode 100644 index 0000000..f01292a --- /dev/null +++ b/src/Generators/NamespacesViews.php @@ -0,0 +1,57 @@ +currentModule(); + $markdown = $this->hasOption('markdown') ? $this->option('markdown') : null; + + // Before Laravel 13.33, make:notification renders the --markdown option without getView(). + $namespaced = $module !== null && is_string($markdown) && $markdown !== '' && ! str_contains($markdown, '::'); + + if ($namespaced) { + $this->input->setOption('markdown', $module->name.'::'.$markdown); + } + + $this->buildingClass = true; + + try { + return parent::buildClass($name); + } finally { + $this->buildingClass = false; + + if ($namespaced) { + $this->input->setOption('markdown', $markdown); + } + } + } + + /** + * Not marked as an override: make:notification has getView() only since Laravel 13.33. + */ + protected function getView() + { + $view = parent::getView(); + $module = $this->currentModule(); + + return $this->buildingClass && $module !== null && ! str_contains($view, '::') ? $module->name.'::'.$view : $view; + } +} diff --git a/src/Generators/NotificationMakeCommand.php b/src/Generators/NotificationMakeCommand.php new file mode 100644 index 0000000..3ccd7c9 --- /dev/null +++ b/src/Generators/NotificationMakeCommand.php @@ -0,0 +1,15 @@ +currentModule(); + + if ($module === null) { + return parent::handle(); + } + + if (GeneratorCommand::handle() === false) { + return false; + } + + $provider = $this->qualifyClass($this->getNameInput()); + + ComposerJson::read($module->path.'/composer.json')->addProvider($provider)->save(); + + $composerJson = $this->laravel->make(ApplicationComposer::class)->relative($module->path.'/composer.json'); + + $this->components->info("Provider [{$provider}] registered in [{$composerJson}]. Run \"php artisan module:sync\" to install it."); + + return null; + } +} diff --git a/src/Generators/ResolvesModule.php b/src/Generators/ResolvesModule.php new file mode 100644 index 0000000..6844ffc --- /dev/null +++ b/src/Generators/ResolvesModule.php @@ -0,0 +1,21 @@ +laravel->make(ModuleContext::class)->module(); + } +} diff --git a/src/Generators/SeederMakeCommand.php b/src/Generators/SeederMakeCommand.php new file mode 100644 index 0000000..0599208 --- /dev/null +++ b/src/Generators/SeederMakeCommand.php @@ -0,0 +1,26 @@ +currentModule(); + + return $module === null ? parent::rootNamespace() : $module->namespace.'\\Database\\Seeders\\'; + } +} diff --git a/src/Generators/TestMakeCommand.php b/src/Generators/TestMakeCommand.php new file mode 100644 index 0000000..2d4804d --- /dev/null +++ b/src/Generators/TestMakeCommand.php @@ -0,0 +1,39 @@ +currentModule(); + + return $module === null ? parent::rootNamespace() : $module->namespace.'\\Tests'; + } + + #[Override] + protected function getPath($name) + { + $module = $this->currentModule(); + + if ($module === null) { + return parent::getPath($name); + } + + return $module->path.'/tests'.str_replace('\\', '/', Str::replaceFirst($this->rootNamespace(), '', $name)).'.php'; + } +} diff --git a/src/Generators/ViewMakeCommand.php b/src/Generators/ViewMakeCommand.php new file mode 100644 index 0000000..efbccd3 --- /dev/null +++ b/src/Generators/ViewMakeCommand.php @@ -0,0 +1,49 @@ +currentModule(); + + if ($module === null) { + return parent::getTestPath(); + } + + $class = Str::after($this->testClassFullyQualifiedName(), $module->namespace.'\\Tests\\'); + + return $module->path.'/tests/'.str_replace('\\', '/', $class).'Test.php'; + } + + #[Override] + protected function testClassFullyQualifiedName() + { + $module = $this->currentModule(); + + return ($module === null ? '' : $module->namespace.'\\').parent::testClassFullyQualifiedName(); + } + + #[Override] + protected function testViewName() + { + $module = $this->currentModule(); + + return ($module === null ? '' : $module->name.'::').parent::testViewName(); + } +} From a3366e466c5fe8f3998b9d75dc7cb6dc1e0a8b47 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 09:36:46 +0300 Subject: [PATCH 06/38] Add the module lifecycle commands module:make renders a module template, module:sync keeps the composer.json of the application in line with the modules, module:delete uninstalls a module before deleting it, and module:doctor checks the setup. Composer runs through the Process facade with validated package names, and the module vendor is excluded from Packagist. --- .../stubs/module/default/composer.json.stub | 17 + .../default/database/factories/.gitkeep | 0 .../default/database/migrations/.gitkeep | 0 .../module/default/database/seeders/.gitkeep | 0 .../stubs/module/default/src/Models/.gitkeep | 0 resources/stubs/module/default/tests/.gitkeep | 0 src/Commands/CacheCommand.php | 24 ++ src/Commands/ClearCommand.php | 24 ++ src/Commands/DeleteCommand.php | 115 ++++++ src/Commands/DoctorCommand.php | 217 +++++++++++ src/Commands/ListCommand.php | 89 +++++ src/Commands/MakeCommand.php | 139 +++++++ src/Commands/SyncCommand.php | 72 ++++ src/Scaffold/ApplicationComposer.php | 89 +++++ src/Scaffold/ComposerJson.php | 362 ++++++++++++++++++ src/Scaffold/ComposerRunner.php | 77 ++++ src/Scaffold/InstalledPackages.php | 71 ++++ src/Scaffold/TemplateRenderer.php | 122 ++++++ 18 files changed, 1418 insertions(+) create mode 100644 resources/stubs/module/default/composer.json.stub create mode 100644 resources/stubs/module/default/database/factories/.gitkeep create mode 100644 resources/stubs/module/default/database/migrations/.gitkeep create mode 100644 resources/stubs/module/default/database/seeders/.gitkeep create mode 100644 resources/stubs/module/default/src/Models/.gitkeep create mode 100644 resources/stubs/module/default/tests/.gitkeep create mode 100644 src/Commands/CacheCommand.php create mode 100644 src/Commands/ClearCommand.php create mode 100644 src/Commands/DeleteCommand.php create mode 100644 src/Commands/DoctorCommand.php create mode 100644 src/Commands/ListCommand.php create mode 100644 src/Commands/MakeCommand.php create mode 100644 src/Commands/SyncCommand.php create mode 100644 src/Scaffold/ApplicationComposer.php create mode 100644 src/Scaffold/ComposerJson.php create mode 100644 src/Scaffold/ComposerRunner.php create mode 100644 src/Scaffold/InstalledPackages.php create mode 100644 src/Scaffold/TemplateRenderer.php diff --git a/resources/stubs/module/default/composer.json.stub b/resources/stubs/module/default/composer.json.stub new file mode 100644 index 0000000..88e5095 --- /dev/null +++ b/resources/stubs/module/default/composer.json.stub @@ -0,0 +1,17 @@ +{ + "name": "{{ package }}", + "description": "The {{ name }} module.", + "type": "library", + "autoload": { + "psr-4": { + "{{ namespace|json }}\\": "src/", + "{{ namespace|json }}\\Database\\Factories\\": "database/factories/", + "{{ namespace|json }}\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "{{ namespace|json }}\\Tests\\": "tests/" + } + } +} diff --git a/resources/stubs/module/default/database/factories/.gitkeep b/resources/stubs/module/default/database/factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resources/stubs/module/default/database/migrations/.gitkeep b/resources/stubs/module/default/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resources/stubs/module/default/database/seeders/.gitkeep b/resources/stubs/module/default/database/seeders/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resources/stubs/module/default/src/Models/.gitkeep b/resources/stubs/module/default/src/Models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resources/stubs/module/default/tests/.gitkeep b/resources/stubs/module/default/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/Commands/CacheCommand.php b/src/Commands/CacheCommand.php new file mode 100644 index 0000000..227f2d9 --- /dev/null +++ b/src/Commands/CacheCommand.php @@ -0,0 +1,24 @@ +cache(); + + $this->components->info('Modules cached successfully.'); + + return self::SUCCESS; + } +} diff --git a/src/Commands/ClearCommand.php b/src/Commands/ClearCommand.php new file mode 100644 index 0000000..43da7c3 --- /dev/null +++ b/src/Commands/ClearCommand.php @@ -0,0 +1,24 @@ +clearCache(); + + $this->components->info('Module cache cleared successfully.'); + + return self::SUCCESS; + } +} diff --git a/src/Commands/DeleteCommand.php b/src/Commands/DeleteCommand.php new file mode 100644 index 0000000..5fde146 --- /dev/null +++ b/src/Commands/DeleteCommand.php @@ -0,0 +1,115 @@ +get($this->argument('name')); + + if (is_link($module->path)) { + $this->components->error("[{$composer->relative($module->path)}] is a symbolic link. Remove the module by hand."); + + return self::FAILURE; + } + + if (! $this->confirmed($module->name)) { + return self::FAILURE; + } + + $root = $composer->composerJson(); + $original = $files->get($composer->composerJsonPath()); + $root->removeAutoloadDev(array_keys($composer->autoloadDev($module))); + + if ($this->option('no-update')) { + $root->removeRequire($module->package); + } + + $vendorLink = $composer->installed($root)->path($module->package); + $root->save(); + } catch (ModulesException $exception) { + $this->components->error($exception->getMessage()); + + return self::FAILURE; + } + + // Uninstall first: when Composer fails, the module and composer.json are left as they were. + if (! $this->option('no-update') && ! $runner->remove([$module->package], $this->streamOutput(...))) { + $files->replace($composer->composerJsonPath(), $original); + $this->components->error('Composer failed to remove the module, nothing was deleted.'); + + return self::FAILURE; + } + + // deleteDirectory() reports success even when some files could not be deleted. + $files->deleteDirectory($module->path); + $modules->refresh(); + + if (file_exists($module->path)) { + $this->components->error("[{$composer->relative($module->path)}] could not be deleted completely. Delete it by hand."); + + return self::FAILURE; + } + + // Composer does not remove the link of a path package whose directory is gone. + if (is_link($vendorLink) && ! file_exists($vendorLink)) { + $files->delete($vendorLink); + } + + $this->components->info("Module [{$module->name}] deleted."); + + if ($this->option('no-update')) { + $this->components->warn('Uninstall it with: '.$runner->commandLine('update', [$module->package])); + } + + return self::SUCCESS; + } + + private function confirmed(string $name): bool + { + if ($this->option('force')) { + return true; + } + + if (! $this->input->isInteractive()) { + $this->components->error('Use --force to delete a module in non-interactive mode.'); + + return false; + } + + if ($this->components->confirm("Delete the [{$name}] module and all of its files?")) { + return true; + } + + $this->components->warn('The module was not deleted.'); + + return false; + } + + private function streamOutput(string $type, string $line): void + { + $this->output->write($line); + } +} diff --git a/src/Commands/DoctorCommand.php b/src/Commands/DoctorCommand.php new file mode 100644 index 0000000..73382c6 --- /dev/null +++ b/src/Commands/DoctorCommand.php @@ -0,0 +1,217 @@ +errors = $this->warnings = 0; + + try { + $root = $composer->composerJson(); + $installed = $composer->installed($root); + $manifest = $modules->manifest(); + + $this->report('Configuration', $this->checkConfig($modules, $composer)); + + foreach ($modules->all() as $name => $module) { + $this->report($name, $this->checkModule($module, $manifest[$name], $root, $installed, $composer)); + } + } catch (ModulesException $exception) { + $this->components->error($exception->getMessage()); + + return self::FAILURE; + } + + $this->newLine(); + + if ($this->errors > 0) { + $this->components->error("Found {$this->errors} error(s) and {$this->warnings} warning(s)."); + + return self::FAILURE; + } + + $this->warnings > 0 + ? $this->components->warn("Found {$this->warnings} warning(s).") + : $this->components->info('No problems found.'); + + return self::SUCCESS; + } + + /** + * @return list Problems: whether it is an error, and the message. + */ + private function checkConfig(ModuleRepository $modules, ApplicationComposer $composer): array + { + $config = $this->config(); + $problems = []; + + if (($unknown = array_diff(array_keys((array) $config->get('modules', [])), self::CONFIG_KEYS)) !== []) { + $problems[] = [false, 'Unknown keys in config/modules.php: '.implode(', ', $unknown).'.']; + } + + if ($modules->isCacheStale()) { + $problems[] = [false, 'The module cache is outdated: run "php artisan module:cache".']; + } + + if (is_array($config->get('octane.watch')) && $composer->relative($modules->modulesPath()) === $modules->modulesPath()) { + $problems[] = [false, 'The modules path is outside of the application, so "octane:start --watch" does not watch it.']; + } + + return $problems; + } + + /** + * @param ModuleManifest $manifest + * @return list + */ + private function checkModule(Module $module, array $manifest, ComposerJson $root, InstalledPackages $installed, ApplicationComposer $composer): array + { + $composerJson = ComposerJson::read($module->path.'/composer.json'); + $problems = []; + + if (! $root->requires($module->package)) { + $problems[] = [true, "The application does not require [{$module->package}]: run \"php artisan module:sync\"."]; + } + + if (! $root->isExcludedFromPackagist($module->package)) { + $problems[] = [true, "[{$module->package}] can be installed from Packagist: run \"php artisan module:sync\" to exclude the vendor."]; + } + + if (! $installed->has($module->package)) { + $problems[] = [true, "[{$module->package}] is not installed: run \"php artisan module:sync\"."]; + } elseif (realpath($installed->path($module->package)) !== realpath($module->path)) { + $problems[] = [true, "[{$composer->relative($installed->path($module->package))}] is not a link to [{$composer->relative($module->path)}]."]; + } elseif (! $installed->isCurrent($module->package, $composerJson->toArray())) { + $problems[] = [false, 'composer.json changed since the module was installed: run "php artisan module:sync".']; + } + + $autoloadDev = (array) $root->get('autoload-dev.psr-4'); + + foreach ($composer->autoloadDev($module) as $namespace => $paths) { + if (($autoloadDev[$namespace] ?? null) !== $paths) { + $problems[] = [false, "The application does not autoload [{$namespace}] for development: run \"php artisan module:sync\"."]; + } + } + + if (! is_dir($module->sourcePath)) { + $problems[] = [true, "The directory of the [{$module->namespace}] namespace does not exist."]; + } + + $autoload = (array) $composerJson->get('autoload.psr-4'); + + foreach (['database/factories' => 'Database\\Factories', 'database/seeders' => 'Database\\Seeders'] as $directory => $namespace) { + $namespace = $module->namespace.'\\'.$namespace.'\\'; + $mapped = $autoload[$namespace] ?? null; + + if (is_dir($module->path.'/'.$directory) && (! is_string($mapped) || trim((string) preg_replace('{^\./}', '', $mapped), '/') !== $directory)) { + $problems[] = [true, "\"autoload.psr-4\" must map [{$namespace}] to [{$directory}/]."]; + } + } + + foreach ((array) $composerJson->get('extra.laravel.providers') as $provider) { + if (! is_string($provider) || ! class_exists($provider)) { + $problems[] = [true, 'The provider ['.(is_string($provider) ? $provider : get_debug_type($provider)).'] does not exist.']; + } + } + + foreach ($this->unloadedRouteFiles($module, $manifest) as $file) { + $problems[] = [false, "The route file [{$file}] is not in a route group of config/modules.php."]; + } + + return $problems; + } + + /** + * PHP files in "routes" directories of the module that no route group loads. + * + * @param ModuleManifest $manifest + * @return list + */ + private function unloadedRouteFiles(Module $module, array $manifest): array + { + $loaded = array_merge(...array_values(array_map( + static fn (array $directories): array => array_merge(...array_values($directories)), + $manifest['routes'], + ))); + + $files = []; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($module->path, FilesystemIterator::SKIP_DOTS)); + $iterator->setMaxDepth(6); + + /** @var SplFileInfo $file */ + foreach ($iterator as $file) { + $path = substr(str_replace('\\', '/', $file->getPathname()), strlen($module->path) + 1); + + if ($file->getExtension() === 'php' + && preg_match('{(^|/)routes/}', $path) === 1 + && preg_match('{(^|/)(vendor|node_modules|tests|\.[^/]*)/}', $path) !== 1 + && ! in_array($path, $loaded, true)) { + $files[] = $path; + } + } + + sort($files); + + return $files; + } + + /** + * @param list $problems + */ + private function report(string $subject, array $problems): void + { + if ($problems === []) { + $this->components->twoColumnDetail($subject, 'OK'); + + return; + } + + $errors = count(array_filter($problems, static fn (array $problem): bool => $problem[0])); + $this->errors += $errors; + $this->warnings += count($problems) - $errors; + + $this->components->twoColumnDetail($subject, $errors > 0 ? 'FAIL' : 'WARN'); + + foreach ($problems as [$isError, $message]) { + $this->line(' '.($isError ? '✗' : '!').' '.$message); + } + } + + private function config(): Repository + { + return $this->laravel->make('config'); + } +} diff --git a/src/Commands/ListCommand.php b/src/Commands/ListCommand.php new file mode 100644 index 0000000..bcd2965 --- /dev/null +++ b/src/Commands/ListCommand.php @@ -0,0 +1,89 @@ +manifest(); + + if ($manifest === []) { + $this->components->warn("No modules found in [{$composer->relative($modules->modulesPath())}]."); + + return self::SUCCESS; + } + + $verbose = $this->output->isVerbose(); + $rows = []; + + foreach ($manifest as $name => $module) { + $row = [$name, $module['package'], $module['namespace'], $composer->relative($module['path'])]; + + if ($verbose) { + $row[] = $this->resources($module); + } + + $rows[] = $row; + } + + $this->table(['Name', 'Package', 'Namespace', 'Path', ...($verbose ? ['Loads'] : [])], $rows); + + return self::SUCCESS; + } + + /** + * @param ModuleManifest $module + */ + private function resources(array $module): string + { + $seeders = count(self::concrete(array_merge(...array_values($module['seeders'])), Seeder::class)); + $commands = count(self::concrete($module['commands'], SymfonyCommand::class)); + + return implode(', ', array_filter([ + $module['config'] === [] ? null : 'config ('.implode(', ', $module['config']).')', + $module['lang'] ? ($module['json'] ? 'lang (+json)' : 'lang') : null, + $module['views'] ? 'views' : null, + $module['migrations'] ? 'migrations' : null, + ...array_map( + static fn (string $group, array $files): string => "{$group} routes (".array_sum(array_map(count(...), $files)).')', + array_keys($module['routes']), + $module['routes'], + ), + $seeders === 0 ? null : "seeders ({$seeders})", + $commands === 0 ? null : "commands ({$commands})", + ])) ?: '-'; + } + + /** + * The manifest lists every class of the directory; only those that are loaded count. + * + * @param list $classes + * @param class-string $parent + * @return list + */ + private static function concrete(array $classes, string $parent): array + { + return array_values(array_filter( + $classes, + static fn (string $class): bool => is_subclass_of($class, $parent) && ! (new ReflectionClass($class))->isAbstract(), + )); + } +} diff --git a/src/Commands/MakeCommand.php b/src/Commands/MakeCommand.php new file mode 100644 index 0000000..9cc7f58 --- /dev/null +++ b/src/Commands/MakeCommand.php @@ -0,0 +1,139 @@ +" of the application} + {--no-update : Do not run "composer update" for the module}'; + + public function handle( + ModuleRepository $modules, + TemplateRenderer $renderer, + ApplicationComposer $composer, + ComposerRunner $runner, + Filesystem $files, + ): int { + try { + $name = Names::module($this->argument('name')); + $package = Names::package(Config::string('modules.vendor'), $name); + $path = $modules->modulesPath().'/'.$name; + + if (file_exists($path) || is_link($path)) { + $this->components->error("The directory [{$composer->relative($path)}] already exists."); + + return self::FAILURE; + } + + $rendered = $renderer->render($this->template($preset = $this->option('preset') ?? 'default'), [ + 'name' => $name, + 'namespace' => Names::namespace(Config::string('modules.namespace'), $name), + 'package' => $package, + 'vendor' => explode('/', $package)[0], + 'date' => Date::now()->format('Y_m_d_His'), + ]); + + if (! isset($rendered['composer.json'])) { + throw InvalidTemplate::at($preset, 'the template must contain a "composer.json" or "composer.json.stub" file.'); + } + + $this->write($files, $path, $rendered); + + try { + $modules->refresh(); + $module = $modules->get($name); + + if ($module->package !== $package) { + throw InvalidTemplate::at('composer.json', "the package name must be [{$package}], [{$module->package}] given."); + } + + $root = $composer->composerJson(); + $composer->addModule($root, $module, $modules->modulesPath()); + $root->save(); + } catch (Throwable $exception) { + $files->deleteDirectory($path); + $modules->refresh(); + + throw $exception; + } + } catch (ModulesException $exception) { + $this->components->error($exception->getMessage()); + + return self::FAILURE; + } + + $this->components->info("Module [{$name}] created in [{$composer->relative($path)}]."); + + if ($this->option('no-update')) { + $this->components->warn('Install it with: '.$runner->commandLine('update', [$package])); + + return self::SUCCESS; + } + + if (! $runner->update([$package], $this->streamOutput(...))) { + $this->components->error('Composer failed to install the module. Fix the problem and run: '.$runner->commandLine('update', [$package])); + + return self::FAILURE; + } + + return self::SUCCESS; + } + + private function template(string $preset): string + { + if (preg_match('/^[a-z0-9][a-z0-9_-]*$/D', $preset) !== 1) { + throw InvalidTemplate::at($preset, 'the preset name may contain lowercase latin letters, digits, "-" and "_".'); + } + + if (is_dir($template = $this->laravel->basePath('stubs/module/'.$preset))) { + return $template; + } + + if ($preset === 'default') { + return dirname(__DIR__, 2).'/resources/stubs/module/default'; + } + + throw InvalidTemplate::at("stubs/module/{$preset}", 'the preset does not exist.'); + } + + /** + * @param array $rendered + */ + private function write(Filesystem $files, string $path, array $rendered): void + { + try { + foreach ($rendered as $file => $contents) { + $files->ensureDirectoryExists(dirname($path.'/'.$file)); + $files->put($path.'/'.$file, $contents); + } + } catch (Throwable $exception) { + $files->deleteDirectory($path); + + throw $exception; + } + } + + private function streamOutput(string $type, string $line): void + { + $this->output->write($line); + } +} diff --git a/src/Commands/SyncCommand.php b/src/Commands/SyncCommand.php new file mode 100644 index 0000000..ad6ee3a --- /dev/null +++ b/src/Commands/SyncCommand.php @@ -0,0 +1,72 @@ +refresh(); + + $root = $composer->composerJson(); + $installed = $composer->installed($root); + $outdated = []; + + foreach ($modules->all() as $module) { + $changed = $composer->addModule($root, $module, $modules->modulesPath()); + + if ($changed || ! $installed->isCurrent($module->package, ComposerJson::read($module->path.'/composer.json')->toArray())) { + $outdated[] = $module->package; + } + } + + $root->save(); + } catch (ModulesException $exception) { + $this->components->error($exception->getMessage()); + + return self::FAILURE; + } + + if ($outdated === []) { + $this->components->info('Modules are in sync with Composer.'); + + return self::SUCCESS; + } + + if ($this->option('no-update')) { + $this->components->warn('Install the outdated modules with: '.$runner->commandLine('update', $outdated)); + + return self::SUCCESS; + } + + if (! $runner->update($outdated, $this->streamOutput(...))) { + $this->components->error('Composer failed to install the modules. Fix the problem and run: '.$runner->commandLine('update', $outdated)); + + return self::FAILURE; + } + + $this->components->info('Modules are in sync with Composer.'); + + return self::SUCCESS; + } + + private function streamOutput(string $type, string $line): void + { + $this->output->write($line); + } +} diff --git a/src/Scaffold/ApplicationComposer.php b/src/Scaffold/ApplicationComposer.php new file mode 100644 index 0000000..7beb1a6 --- /dev/null +++ b/src/Scaffold/ApplicationComposer.php @@ -0,0 +1,89 @@ +basePath = rtrim(str_replace('\\', '/', $basePath), '/'); + } + + public function composerJsonPath(): string + { + return $this->basePath.'/composer.json'; + } + + public function composerJson(): ComposerJson + { + return ComposerJson::read($this->composerJsonPath()); + } + + /** + * Add the module to the root composer.json and tell whether that changed anything. + */ + public function addModule(ComposerJson $root, Module $module, string $modulesPath): bool + { + $before = $root->contents(); + + $root->addModule($module->package, $this->relative($modulesPath).'/*', $this->autoloadDev($module)); + + return $root->contents() !== $before; + } + + /** + * The "autoload-dev" namespaces of the module (its tests) with paths relative to the application: + * Composer does not load the dev autoload of dependencies, so the application has to. + * + * @return array> + */ + public function autoloadDev(Module $module): array + { + $prefix = $this->relative($module->path).'/'; + $autoloadDev = []; + + foreach ((array) ComposerJson::read($module->path.'/composer.json')->get('autoload-dev.psr-4') as $namespace => $paths) { + $paths = array_values(array_filter((array) $paths, is_string(...))); + + if (is_string($namespace) && $paths !== []) { + $paths = array_map(static fn (string $path): string => $prefix.preg_replace('{^\./}', '', $path), $paths); + $autoloadDev[$namespace] = count($paths) === 1 ? $paths[0] : $paths; + } + } + + return $autoloadDev; + } + + public function installed(ComposerJson $root): InstalledPackages + { + $vendor = $root->get('config.vendor-dir'); + + return InstalledPackages::read($this->absolute(is_string($vendor) ? $vendor : 'vendor')); + } + + /** + * A path relative to the application, if it is inside of it. + */ + public function relative(string $path): string + { + $path = rtrim(str_replace('\\', '/', $path), '/'); + + return str_starts_with($path.'/', $this->basePath.'/') ? ltrim(substr($path, strlen($this->basePath)), '/') : $path; + } + + private function absolute(string $path): string + { + return preg_match('{^(/|[A-Za-z]:[/\\\\])}', $path) === 1 ? $path : $this->basePath.'/'.$path; + } +} diff --git a/src/Scaffold/ComposerJson.php b/src/Scaffold/ComposerJson.php new file mode 100644 index 0000000..dc41686 --- /dev/null +++ b/src/Scaffold/ComposerJson.php @@ -0,0 +1,362 @@ +getMessage()}", $exception); + } + + if (! $data instanceof stdClass) { + throw ComposerFailed::because("[{$path}] must contain a JSON object."); + } + + $indent = preg_match('/^([ \t]+)"/m', $json, $matches) === 1 ? $matches[1] : ' '; + + return new self($path, $data, $indent, self::encode($data, $indent)); + } + + /** + * A value by its dot path, with objects as arrays. + */ + public function get(string $key): mixed + { + return Arr::get($this->toArray(), $key); + } + + /** + * @return array + */ + public function toArray(): array + { + return (array) json_decode((string) json_encode($this->data), true); + } + + /** + * Require a module from its path repository, load its dev autoload (tests) from the application + * and never resolve its vendor from Packagist. + * + * @param string $repository The path repository URL, e.g. "modules/*". + * @param array> $autoloadDev Namespaces and their paths relative to the application. + */ + public function addModule(string $package, string $repository, array $autoloadDev): self + { + $this->addPathRepository($repository); + $this->excludeFromPackagist(strstr($package, '/', true).'/*'); + + $require = $this->object($this->data, 'require'); + + // A path package has a "dev-*" version, which "minimum-stability": "stable" refuses without "@dev". + if (! isset($require->{$package})) { + $require->{$package} = '*@dev'; + + if ($this->get('config.sort-packages') === true) { + $this->data->require = $this->sortPackages($require); + } + } + + if ($autoloadDev !== []) { + $psr4 = $this->object($this->object($this->data, 'autoload-dev'), 'psr-4'); + + foreach ($autoloadDev as $namespace => $path) { + $psr4->{$namespace} = $path; + } + } + + return $this; + } + + public function removeRequire(string $package): self + { + if (($this->data->require ?? null) instanceof stdClass) { + unset($this->data->require->{$package}); + } + + return $this; + } + + /** + * @param list $namespaces + */ + public function removeAutoloadDev(array $namespaces): self + { + $autoloadDev = $this->data->{'autoload-dev'} ?? null; + $psr4 = $autoloadDev instanceof stdClass ? ($autoloadDev->{'psr-4'} ?? null) : null; + + if ($psr4 instanceof stdClass) { + foreach ($namespaces as $namespace) { + unset($psr4->{$namespace}); + } + } + + return $this; + } + + /** + * Register a service provider for Laravel package discovery. + */ + public function addProvider(string $provider): self + { + $laravel = $this->object($this->object($this->data, 'extra'), 'laravel'); + $providers = is_array($laravel->providers ?? null) ? $laravel->providers : []; + + if (! in_array($provider, $providers, true)) { + $laravel->providers = [...$providers, $provider]; + } + + return $this; + } + + public function requires(string $package): bool + { + $require = $this->data->require ?? null; + + return $require instanceof stdClass && is_string($require->{$package} ?? null); + } + + /** + * Whether Composer never looks the package up on Packagist. + */ + public function isExcludedFromPackagist(string $package): bool + { + foreach ($this->repositories() as $repository) { + if ($this->isPackagist($repository)) { + if (is_array($repository->only ?? null)) { + return ! $this->matches($package, $repository->only); + } + + return $this->matches($package, is_array($repository->exclude ?? null) ? $repository->exclude : []); + } + } + + return $this->isPackagistDisabled(); + } + + public function isDirty(): bool + { + return self::encode($this->data, $this->indent) !== $this->saved; + } + + /** + * The file content as it will be saved. + */ + public function contents(): string + { + return self::encode($this->data, $this->indent); + } + + public function save(): void + { + if ($this->isDirty()) { + (new Filesystem)->replace($this->path, $this->saved = $this->contents()); + } + } + + private function addPathRepository(string $url): void + { + foreach ($this->repositories() as $repository) { + if ($repository instanceof stdClass && ($repository->type ?? null) === 'path' && ($repository->url ?? null) === $url) { + return; + } + } + + $this->addRepository('modules', (object) ['type' => 'path', 'url' => $url, 'options' => (object) ['symlink' => true]]); + } + + /** + * Modules are required with "*@dev" from a path repository. Without this, a missing module + * directory (another branch, a partial checkout) makes Composer install a package with + * the same name from Packagist instead of failing. + */ + private function excludeFromPackagist(string $pattern): void + { + foreach ($this->repositories() as $repository) { + if (! $this->isPackagist($repository)) { + continue; + } + + // A repository restricted with "only" does not serve the vendor anyway. + if (! isset($repository->only)) { + $exclude = is_array($repository->exclude ?? null) ? $repository->exclude : []; + + if (! in_array($pattern, $exclude, true)) { + $repository->exclude = [...$exclude, $pattern]; + } + } + + return; + } + + if ($this->isPackagistDisabled()) { + return; + } + + $this->addRepository('packagist', (object) ['type' => 'composer', 'url' => self::PACKAGIST, 'exclude' => [$pattern]]); + + if ($this->data->repositories instanceof stdClass) { + $this->data->repositories->{'packagist.org'} = false; + } else { + $this->addRepository('packagist.org', (object) ['packagist.org' => false]); + } + } + + /** + * Composer replaces the default Packagist repository with a repository of this URL. + * + * @phpstan-assert-if-true stdClass $repository + */ + private function isPackagist(mixed $repository): bool + { + return $repository instanceof stdClass + && ($repository->type ?? null) === 'composer' + && is_string($repository->url ?? null) + && preg_match('{^https?://(?:[a-z0-9-.]+\.)?packagist\.org(/|$)}', $repository->url) === 1; + } + + private function isPackagistDisabled(): bool + { + foreach ($this->repositories() as $name => $repository) { + if (in_array($name, ['packagist', 'packagist.org'], true) && $repository === false) { + return true; + } + + if ($repository instanceof stdClass && (($repository->{'packagist.org'} ?? null) === false || ($repository->packagist ?? null) === false)) { + return true; + } + } + + return false; + } + + /** + * @param array $patterns Package names with "*" wildcards. + */ + private function matches(string $package, array $patterns): bool + { + foreach ($patterns as $pattern) { + if (is_string($pattern) && preg_match('{^'.str_replace('\\*', '.*', preg_quote($pattern)).'$}iD', $package) === 1) { + return true; + } + } + + return false; + } + + /** + * @return array + */ + private function repositories(): array + { + $repositories = $this->data->repositories ?? []; + + return $repositories instanceof stdClass ? get_object_vars($repositories) : (array) $repositories; + } + + /** + * Append a repository to the list, or add it by name when repositories are an object. + */ + private function addRepository(string $name, stdClass $repository): void + { + $repositories = $this->data->repositories ?? []; + + if ($repositories instanceof stdClass) { + $key = $name; + + for ($i = 2; property_exists($repositories, $key); $i++) { + $key = $name.'-'.$i; + } + + $repositories->{$key} = $repository; + + return; + } + + if (! is_array($repositories)) { + throw ComposerFailed::because("[{$this->path}] \"repositories\" must be an array or an object."); + } + + $this->data->repositories = [...$repositories, $repository]; + } + + private function object(stdClass $parent, string $key): stdClass + { + $value = $parent->{$key} ?? null; + + if ($value instanceof stdClass) { + return $value; + } + + if ($value !== null && $value !== []) { + throw ComposerFailed::because("[{$this->path}] \"{$key}\" must be an object."); + } + + return $parent->{$key} = new stdClass; + } + + /** + * Composer's "sort-packages" order: platform packages first (PHP, HHVM, extensions, libraries, + * then the others), then packages by name. It is the order of Composer's JsonManipulator. + */ + private function sortPackages(stdClass $packages): stdClass + { + $packages = get_object_vars($packages); + + $weight = static fn (string $name): string => preg_match(self::PLATFORM_PACKAGE, $name) === 1 + ? (string) preg_replace(['/^php/', '/^hhvm/', '/^ext/', '/^lib/', '/^\D/'], ['0-$0', '1-$0', '2-$0', '3-$0', '4-$0'], $name) + : '5-'.$name; + + uksort($packages, static fn (string $a, string $b): int => strnatcmp($weight($a), $weight($b))); + + return (object) $packages; + } + + private static function encode(stdClass $data, string $indent): string + { + $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + + if ($indent !== ' ') { + $json = (string) preg_replace_callback('/^(?: {4})+/m', static fn (array $matches): string => str_repeat($indent, intdiv(strlen($matches[0]), 4)), $json); + } + + return $json."\n"; + } +} diff --git a/src/Scaffold/ComposerRunner.php b/src/Scaffold/ComposerRunner.php new file mode 100644 index 0000000..8ddfd62 --- /dev/null +++ b/src/Scaffold/ComposerRunner.php @@ -0,0 +1,77 @@ + $packages + * @param (Closure(string, string): void)|null $output + */ + public function update(array $packages, ?Closure $output = null): bool + { + return $this->run('update', $packages, $output); + } + + /** + * @param list $packages + * @param (Closure(string, string): void)|null $output + */ + public function remove(array $packages, ?Closure $output = null): bool + { + return $this->run('remove', $packages, $output); + } + + /** + * The command line to run by hand. + * + * @param list $packages + */ + public function commandLine(string $action, array $packages): string + { + return implode(' ', ['composer', $action, ...array_map(Names::assertPackage(...), $packages)]); + } + + /** + * @param list $packages + * @return list + */ + public function command(string $action, array $packages): array + { + $composer = is_file($this->basePath.'/composer.phar') + ? [php_binary(), 'composer.phar'] + : ['composer']; + + return [...$composer, $action, '--', ...array_map(Names::assertPackage(...), $packages)]; + } + + /** + * @param list $packages + * @param (Closure(string, string): void)|null $output + */ + private function run(string $action, array $packages, ?Closure $output): bool + { + return Process::path($this->basePath) + ->env(['COMPOSER_MEMORY_LIMIT' => '-1']) + ->forever() + ->run($this->command($action, $packages), $output) + ->successful(); + } +} diff --git a/src/Scaffold/InstalledPackages.php b/src/Scaffold/InstalledPackages.php new file mode 100644 index 0000000..d8b6d48 --- /dev/null +++ b/src/Scaffold/InstalledPackages.php @@ -0,0 +1,71 @@ +> $packages + */ + private function __construct( + private string $vendorPath, + private array $packages, + ) {} + + public static function read(string $vendorPath): self + { + $json = is_file($file = $vendorPath.'/composer/installed.json') ? json_decode((string) file_get_contents($file), true) : null; + $packages = []; + + foreach ((array) (is_array($json) && isset($json['packages']) ? $json['packages'] : $json) as $package) { + if (is_array($package) && is_string($package['name'] ?? null)) { + $packages[$package['name']] = $package; + } + } + + return new self($vendorPath, $packages); + } + + public function has(string $package): bool + { + return isset($this->packages[$package]); + } + + /** + * Whether the installed copy of the package metadata matches its composer.json: + * a changed "require", "autoload" or "extra" (providers) needs "composer update". + * + * @param array $composerJson + */ + public function isCurrent(string $package, array $composerJson): bool + { + $installed = $this->packages[$package] ?? null; + + if ($installed === null) { + return false; + } + + foreach (['require', 'autoload', 'extra'] as $key) { + if (($installed[$key] ?? []) != ($composerJson[$key] ?? [])) { + return false; + } + } + + return true; + } + + /** + * The directory the package is installed in. + */ + public function path(string $package): string + { + return $this->vendorPath.'/'.$package; + } +} diff --git a/src/Scaffold/TemplateRenderer.php b/src/Scaffold/TemplateRenderer.php new file mode 100644 index 0000000..bf7874e --- /dev/null +++ b/src/Scaffold/TemplateRenderer.php @@ -0,0 +1,122 @@ + $variables + * @return array File contents by their path relative to the module. + */ + public function render(string $template, array $variables): array + { + $template = rtrim(str_replace('\\', '/', $template), '/'); + + if (! is_dir($template)) { + throw InvalidTemplate::at($template, 'the directory does not exist.'); + } + + $files = []; + $sources = []; + + /** @var SplFileInfo $file */ + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($template, FilesystemIterator::SKIP_DOTS)) as $file) { + $source = substr(str_replace('\\', '/', $file->getPathname()), strlen($template) + 1); + $isStub = str_ends_with($source, '.stub'); + $path = $this->renderPath($isStub ? substr($source, 0, -5) : $source, $source, $variables); + + if (isset($files[$path])) { + throw InvalidTemplate::at($source, "another file is also rendered to [{$path}]."); + } + + $contents = (string) file_get_contents($file->getPathname()); + $files[$path] = $isStub ? $this->renderString($contents, $source, $variables) : $contents; + $sources[$path] = $source; + } + + foreach ($sources as $path => $source) { + for ($directory = dirname($path); $directory !== '.'; $directory = dirname($directory)) { + if (isset($files[$directory])) { + throw InvalidTemplate::at($sources[$directory], "the file is rendered to [{$directory}], which is also the directory of [{$path}]."); + } + } + } + + ksort($files, SORT_STRING); + + return $files; + } + + /** + * @param array $variables + */ + private function renderPath(string $path, string $source, array $variables): string + { + return implode('/', array_map(function (string $segment) use ($source, $variables): string { + $segment = $this->renderString($segment, $source, $variables); + + if (in_array($segment, ['', '.', '..'], true) || strpbrk($segment, "/\\\0:") !== false) { + throw InvalidTemplate::at($source, "the path renders to an unsafe name [{$segment}]."); + } + + return $segment; + }, explode('/', $path))); + } + + /** + * @param array $variables + */ + private function renderString(string $string, string $source, array $variables): string + { + return (string) preg_replace_callback(self::PLACEHOLDER, function (array $matches) use ($source, $variables): string { + if (! array_key_exists($matches[1], $variables)) { + throw InvalidTemplate::at($source, "unknown variable [{$matches[1]}]."); + } + + $value = $variables[$matches[1]]; + + foreach (array_filter(array_map(trim(...), explode('|', $matches[2]))) as $filter) { + $value = $this->filter($filter, $value, $source); + } + + return $value; + }, $string); + } + + private function filter(string $filter, string $value, string $source): string + { + return match ($filter) { + 'studly' => Str::studly($value), + 'camel' => Str::camel($value), + 'snake' => Str::snake(Str::camel($value)), + 'kebab' => Str::kebab(Str::camel($value)), + 'plural' => Str::plural($value), + 'singular' => Str::singular($value), + 'lower' => Str::lower($value), + 'upper' => Str::upper($value), + 'title' => Str::title($value), + 'json' => substr(json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 1, -1), + default => throw InvalidTemplate::at($source, "unknown filter [{$filter}]."), + }; + } +} From a438a81a0488bd8a6a2aefab2c7abb836310b280 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 09:36:46 +0300 Subject: [PATCH 07/38] Wire the package in one service provider The provider registers the manifest, the conventions, the generators and the commands, hooks into optimize and about, and publishes the config and the module template. --- config/modules.php | 69 ++++++++++++++++ src/ModulesServiceProvider.php | 143 +++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 config/modules.php create mode 100644 src/ModulesServiceProvider.php diff --git a/config/modules.php b/config/modules.php new file mode 100644 index 0000000..c563eff --- /dev/null +++ b/config/modules.php @@ -0,0 +1,69 @@ + base_path('modules'), + + /* + |-------------------------------------------------------------------------- + | Namespace And Vendor Of New Modules + |-------------------------------------------------------------------------- + | + | "module:make blog" creates the "Modules\Blog" namespace and the + | "app/blog" Composer package. The vendor is excluded from Packagist, + | so choose one that you do not publish public packages under. + | + */ + + 'namespace' => 'Modules', + + 'vendor' => 'app', + + /* + |-------------------------------------------------------------------------- + | Route Groups + |-------------------------------------------------------------------------- + | + | Every PHP file found in a module's "path" directory is loaded inside a + | route group with the other attributes. Nested directories are added to + | the prefix: "routes/api/v1/posts.php" is loaded with the "api/v1" prefix. + | + */ + + 'routes' => [ + 'api' => ['path' => 'routes/api', 'prefix' => 'api', 'middleware' => ['api']], + 'web' => ['path' => 'routes/web', 'middleware' => ['web']], + ], + + /* + |-------------------------------------------------------------------------- + | Generator Namespaces + |-------------------------------------------------------------------------- + | + | "make:* --module=blog" places classes where Laravel places them in the + | application ("Models", "Http\Controllers", ...). Map a command to + | another sub-namespace of the module here. Module commands are + | discovered in the namespace of "make:command". + | + | Example: 'make:controller' => 'UI\API\Controllers' + | + */ + + 'generators' => [ + // + ], + +]; diff --git a/src/ModulesServiceProvider.php b/src/ModulesServiceProvider.php new file mode 100644 index 0000000..43e4ad6 --- /dev/null +++ b/src/ModulesServiceProvider.php @@ -0,0 +1,143 @@ +mergeConfigFrom(__DIR__.'/../config/modules.php', 'modules'); + + $this->app->singleton(ModuleRepository::class, static fn (Application $app): ModuleRepository => new ModuleRepository( + self::manifestBuilder(), + new ManifestCache($app->make('files'), self::cachePath($app), $app->basePath()), + )); + + (new ResourceRegistrar($this->app, $this->manifest()))->registerConfig(); + + if ($this->app->runningInConsole()) { + $this->app->singleton(ApplicationComposer::class, static fn (Application $app): ApplicationComposer => new ApplicationComposer($app->basePath())); + $this->app->singleton(ComposerRunner::class, static fn (Application $app): ComposerRunner => new ComposerRunner($app->basePath())); + + ModuleOption::register($this->app); + } + } + + public function boot(): void + { + $manifest = $this->manifest(); + + (new ResourceRegistrar($this->app, $manifest))->boot(); + + if (! $this->app->routesAreCached()) { + (new RouteRegistrar($this->app->make('router'), Config::array('modules.routes', []), $manifest))->register(); + } + + if ($this->app->runningInConsole()) { + $this->bootConsole($manifest); + } + } + + /** + * @param Manifest $manifest + */ + private function bootConsole(array $manifest): void + { + $modules = $this->app->make(ModuleRepository::class); + $composer = $this->app->make(ApplicationComposer::class); + + (new ConsoleRegistrar($this->app, $manifest, $modules->modulesPath()))->register(); + + $this->commands([ + Commands\CacheCommand::class, + Commands\ClearCommand::class, + Commands\DeleteCommand::class, + Commands\DoctorCommand::class, + Commands\ListCommand::class, + Commands\MakeCommand::class, + Commands\SyncCommand::class, + ]); + + $this->optimizes(optimize: 'module:cache', clear: 'module:clear', key: 'modules'); + + AboutCommand::add('Modules', static fn (): array => [ + 'Modules' => count($modules->manifest()), + 'Path' => $composer->relative($modules->modulesPath()), + 'Cache' => AboutCommand::format( + $modules->isCached(), + console: static fn (mixed $cached): string => $cached ? 'CACHED' : 'NOT CACHED', + ), + ]); + + $this->publishes([__DIR__.'/../config/modules.php' => $this->app->configPath('modules.php')], 'modules-config'); + $this->publishes([__DIR__.'/../resources/stubs/module/default' => $this->app->basePath('stubs/module/default')], 'modules-stubs'); + } + + /** + * @return Manifest + */ + private function manifest(): array + { + return $this->app->make(ModuleRepository::class)->manifest(); + } + + private static function manifestBuilder(): ManifestBuilder + { + $routes = []; + $generators = Config::array('modules.generators', []); + + foreach (Config::array('modules.routes', []) as $group => $attributes) { + if (! is_array($attributes) || ! is_string($attributes['path'] ?? null)) { + throw InvalidConfiguration::because("the [{$group}] route group must have a \"path\"."); + } + + $routes[(string) $group] = $attributes['path']; + } + + foreach ($generators as $command => $namespace) { + if (! is_string($namespace)) { + throw InvalidConfiguration::because("the namespace of the [{$command}] generator must be a string."); + } + } + + return new ManifestBuilder( + Config::string('modules.path'), + $routes, + $generators['make:command'] ?? 'Console\\Commands', + ); + } + + /** + * Like the config and route caches: "bootstrap/cache/modules.php", or the MODULES_CACHE path. + */ + private static function cachePath(Application $app): string + { + $path = Env::get('MODULES_CACHE'); + + if (! is_string($path) || $path === '') { + return $app->bootstrapPath('cache/modules.php'); + } + + return str_starts_with($path, '/') || preg_match('{^[A-Za-z]:[/\\\\]}', $path) === 1 ? $path : $app->basePath($path); + } +} From fd8bae9bc28de0d3551ae8565a87189723e1ca15 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 09:36:54 +0300 Subject: [PATCH 08/38] Add the test infrastructure and the fixture application Every test runs in its own copy of a small application with two modules, from its own working directory, and Composer runs are faked. --- tests/Fixtures/app/app/.gitkeep | 0 tests/Fixtures/app/bootstrap/cache/.gitkeep | 0 tests/Fixtures/app/composer.json | 17 ++ tests/Fixtures/app/modules/blog/composer.json | 17 ++ .../Fixtures/app/modules/blog/config/blog.php | 6 + .../blog/database/factories/PostFactory.php | 17 ++ .../2024_01_01_000000_create_posts_table.php | 22 ++ .../blog/database/seeders/BlogSeeder.php | 13 + .../database/seeders/CategorySeeder_10.php | 13 + .../Deployment/BaseDeploymentSeeder_0.php | 7 + .../seeders/Deployment/DemoSeeder_1.php | 13 + .../blog/database/seeders/PostSeeder_2.php | 13 + .../blog/database/seeders/SeederHelper_3.php | 5 + .../modules/blog/database/seeders/notes.txt | 1 + tests/Fixtures/app/modules/blog/lang/en.json | 3 + .../app/modules/blog/lang/en/messages.php | 5 + .../blog/resources/views/index.blade.php | 1 + .../app/modules/blog/routes/api/README.md | 1 + .../app/modules/blog/routes/api/posts.php | 5 + .../blog/routes/api/v1/admin/stats.php | 5 + .../app/modules/blog/routes/api/v1/feed.php | 5 + .../app/modules/blog/routes/web/pages.php | 5 + .../blog/src/Console/Commands/BlogCommand.php | 7 + .../Commands/Nested/ArchivePostsCommand.php | 19 ++ .../Console/Commands/PublishPostsCommand.php | 19 ++ .../blog/src/Console/Commands/helpers.php | 3 + .../app/modules/blog/src/Models/Post.php | 15 ++ .../Fixtures/app/modules/blog/tests/.gitkeep | 0 tests/Fixtures/app/modules/notes/README.md | 1 + .../app/modules/shop-order/composer.json | 12 + .../modules/shop-order/config/shop-order.php | 5 + .../seeders/Deployment/ShipmentSeeder_1.php | 13 + .../database/seeders/OrderSeeder_1.php | 13 + .../app/modules/shop-order/lang/en/orders.php | 5 + .../modules/shop-order/routes/web/orders.php | 5 + .../modules/shop-order/src/Models/Order.php | 7 + .../app/storage/framework/cache/.gitkeep | 0 .../app/storage/framework/sessions/.gitkeep | 0 .../app/storage/framework/views/.gitkeep | 0 tests/Fixtures/app/storage/logs/.gitkeep | 0 tests/Pest.php | 12 + tests/TemporaryDirectory.php | 27 ++ tests/TestCase.php | 237 ++++++++++++++++++ tests/UnitTestCase.php | 45 ++++ 44 files changed, 619 insertions(+) create mode 100644 tests/Fixtures/app/app/.gitkeep create mode 100644 tests/Fixtures/app/bootstrap/cache/.gitkeep create mode 100644 tests/Fixtures/app/composer.json create mode 100644 tests/Fixtures/app/modules/blog/composer.json create mode 100644 tests/Fixtures/app/modules/blog/config/blog.php create mode 100644 tests/Fixtures/app/modules/blog/database/factories/PostFactory.php create mode 100644 tests/Fixtures/app/modules/blog/database/migrations/2024_01_01_000000_create_posts_table.php create mode 100644 tests/Fixtures/app/modules/blog/database/seeders/BlogSeeder.php create mode 100644 tests/Fixtures/app/modules/blog/database/seeders/CategorySeeder_10.php create mode 100644 tests/Fixtures/app/modules/blog/database/seeders/Deployment/BaseDeploymentSeeder_0.php create mode 100644 tests/Fixtures/app/modules/blog/database/seeders/Deployment/DemoSeeder_1.php create mode 100644 tests/Fixtures/app/modules/blog/database/seeders/PostSeeder_2.php create mode 100644 tests/Fixtures/app/modules/blog/database/seeders/SeederHelper_3.php create mode 100644 tests/Fixtures/app/modules/blog/database/seeders/notes.txt create mode 100644 tests/Fixtures/app/modules/blog/lang/en.json create mode 100644 tests/Fixtures/app/modules/blog/lang/en/messages.php create mode 100644 tests/Fixtures/app/modules/blog/resources/views/index.blade.php create mode 100644 tests/Fixtures/app/modules/blog/routes/api/README.md create mode 100644 tests/Fixtures/app/modules/blog/routes/api/posts.php create mode 100644 tests/Fixtures/app/modules/blog/routes/api/v1/admin/stats.php create mode 100644 tests/Fixtures/app/modules/blog/routes/api/v1/feed.php create mode 100644 tests/Fixtures/app/modules/blog/routes/web/pages.php create mode 100644 tests/Fixtures/app/modules/blog/src/Console/Commands/BlogCommand.php create mode 100644 tests/Fixtures/app/modules/blog/src/Console/Commands/Nested/ArchivePostsCommand.php create mode 100644 tests/Fixtures/app/modules/blog/src/Console/Commands/PublishPostsCommand.php create mode 100644 tests/Fixtures/app/modules/blog/src/Console/Commands/helpers.php create mode 100644 tests/Fixtures/app/modules/blog/src/Models/Post.php create mode 100644 tests/Fixtures/app/modules/blog/tests/.gitkeep create mode 100644 tests/Fixtures/app/modules/notes/README.md create mode 100644 tests/Fixtures/app/modules/shop-order/composer.json create mode 100644 tests/Fixtures/app/modules/shop-order/config/shop-order.php create mode 100644 tests/Fixtures/app/modules/shop-order/database/seeders/Deployment/ShipmentSeeder_1.php create mode 100644 tests/Fixtures/app/modules/shop-order/database/seeders/OrderSeeder_1.php create mode 100644 tests/Fixtures/app/modules/shop-order/lang/en/orders.php create mode 100644 tests/Fixtures/app/modules/shop-order/routes/web/orders.php create mode 100644 tests/Fixtures/app/modules/shop-order/src/Models/Order.php create mode 100644 tests/Fixtures/app/storage/framework/cache/.gitkeep create mode 100644 tests/Fixtures/app/storage/framework/sessions/.gitkeep create mode 100644 tests/Fixtures/app/storage/framework/views/.gitkeep create mode 100644 tests/Fixtures/app/storage/logs/.gitkeep create mode 100644 tests/Pest.php create mode 100644 tests/TemporaryDirectory.php create mode 100644 tests/TestCase.php create mode 100644 tests/UnitTestCase.php diff --git a/tests/Fixtures/app/app/.gitkeep b/tests/Fixtures/app/app/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Fixtures/app/bootstrap/cache/.gitkeep b/tests/Fixtures/app/bootstrap/cache/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Fixtures/app/composer.json b/tests/Fixtures/app/composer.json new file mode 100644 index 0000000..fe5abb9 --- /dev/null +++ b/tests/Fixtures/app/composer.json @@ -0,0 +1,17 @@ +{ + "name": "laraneat/fixture-app", + "type": "project", + "require": { + "php": "^8.3", + "laravel/framework": "^13.0" + }, + "autoload": { + "psr-4": { + "App\\": "app/" + } + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "stable" +} diff --git a/tests/Fixtures/app/modules/blog/composer.json b/tests/Fixtures/app/modules/blog/composer.json new file mode 100644 index 0000000..95760e0 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/composer.json @@ -0,0 +1,17 @@ +{ + "name": "app/blog", + "description": "The blog module.", + "type": "library", + "autoload": { + "psr-4": { + "Modules\\Blog\\": "src/", + "Modules\\Blog\\Database\\Factories\\": "database/factories/", + "Modules\\Blog\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\Blog\\Tests\\": "tests/" + } + } +} diff --git a/tests/Fixtures/app/modules/blog/config/blog.php b/tests/Fixtures/app/modules/blog/config/blog.php new file mode 100644 index 0000000..fb69eda --- /dev/null +++ b/tests/Fixtures/app/modules/blog/config/blog.php @@ -0,0 +1,6 @@ + 'Blog', + 'per_page' => 15, +]; diff --git a/tests/Fixtures/app/modules/blog/database/factories/PostFactory.php b/tests/Fixtures/app/modules/blog/database/factories/PostFactory.php new file mode 100644 index 0000000..c8e64c4 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/database/factories/PostFactory.php @@ -0,0 +1,17 @@ + + */ +class PostFactory extends Factory +{ + public function definition(): array + { + return ['title' => 'Hello']; + } +} diff --git a/tests/Fixtures/app/modules/blog/database/migrations/2024_01_01_000000_create_posts_table.php b/tests/Fixtures/app/modules/blog/database/migrations/2024_01_01_000000_create_posts_table.php new file mode 100644 index 0000000..82c2577 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/database/migrations/2024_01_01_000000_create_posts_table.php @@ -0,0 +1,22 @@ +id(); + $table->string('title'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('posts'); + } +}; diff --git a/tests/Fixtures/app/modules/blog/database/seeders/BlogSeeder.php b/tests/Fixtures/app/modules/blog/database/seeders/BlogSeeder.php new file mode 100644 index 0000000..3d90995 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/database/seeders/BlogSeeder.php @@ -0,0 +1,13 @@ + 'Welcome to the blog', +]; diff --git a/tests/Fixtures/app/modules/blog/resources/views/index.blade.php b/tests/Fixtures/app/modules/blog/resources/views/index.blade.php new file mode 100644 index 0000000..5ee9015 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/resources/views/index.blade.php @@ -0,0 +1 @@ +Blog: {{ $title }} diff --git a/tests/Fixtures/app/modules/blog/routes/api/README.md b/tests/Fixtures/app/modules/blog/routes/api/README.md new file mode 100644 index 0000000..e846db1 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/routes/api/README.md @@ -0,0 +1 @@ +Routes of the blog API. diff --git a/tests/Fixtures/app/modules/blog/routes/api/posts.php b/tests/Fixtures/app/modules/blog/routes/api/posts.php new file mode 100644 index 0000000..1457364 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/routes/api/posts.php @@ -0,0 +1,5 @@ + 'blog posts')->name('blog.posts'); diff --git a/tests/Fixtures/app/modules/blog/routes/api/v1/admin/stats.php b/tests/Fixtures/app/modules/blog/routes/api/v1/admin/stats.php new file mode 100644 index 0000000..2d37580 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/routes/api/v1/admin/stats.php @@ -0,0 +1,5 @@ + 'blog stats'); diff --git a/tests/Fixtures/app/modules/blog/routes/api/v1/feed.php b/tests/Fixtures/app/modules/blog/routes/api/v1/feed.php new file mode 100644 index 0000000..57d8205 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/routes/api/v1/feed.php @@ -0,0 +1,5 @@ + 'blog feed v1'); diff --git a/tests/Fixtures/app/modules/blog/routes/web/pages.php b/tests/Fixtures/app/modules/blog/routes/web/pages.php new file mode 100644 index 0000000..fcd8ca4 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/routes/web/pages.php @@ -0,0 +1,5 @@ + view('blog::index', ['title' => __('blog::messages.welcome')])); diff --git a/tests/Fixtures/app/modules/blog/src/Console/Commands/BlogCommand.php b/tests/Fixtures/app/modules/blog/src/Console/Commands/BlogCommand.php new file mode 100644 index 0000000..d940311 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/src/Console/Commands/BlogCommand.php @@ -0,0 +1,7 @@ +info('blog:archive done'); + + return self::SUCCESS; + } +} diff --git a/tests/Fixtures/app/modules/blog/src/Console/Commands/PublishPostsCommand.php b/tests/Fixtures/app/modules/blog/src/Console/Commands/PublishPostsCommand.php new file mode 100644 index 0000000..801450c --- /dev/null +++ b/tests/Fixtures/app/modules/blog/src/Console/Commands/PublishPostsCommand.php @@ -0,0 +1,19 @@ +info('blog:publish done'); + + return self::SUCCESS; + } +} diff --git a/tests/Fixtures/app/modules/blog/src/Console/Commands/helpers.php b/tests/Fixtures/app/modules/blog/src/Console/Commands/helpers.php new file mode 100644 index 0000000..ed5ec41 --- /dev/null +++ b/tests/Fixtures/app/modules/blog/src/Console/Commands/helpers.php @@ -0,0 +1,3 @@ + */ + use HasFactory; + + protected $guarded = []; +} diff --git a/tests/Fixtures/app/modules/blog/tests/.gitkeep b/tests/Fixtures/app/modules/blog/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Fixtures/app/modules/notes/README.md b/tests/Fixtures/app/modules/notes/README.md new file mode 100644 index 0000000..4921d5a --- /dev/null +++ b/tests/Fixtures/app/modules/notes/README.md @@ -0,0 +1 @@ +A directory without composer.json is not a module. diff --git a/tests/Fixtures/app/modules/shop-order/composer.json b/tests/Fixtures/app/modules/shop-order/composer.json new file mode 100644 index 0000000..037ac49 --- /dev/null +++ b/tests/Fixtures/app/modules/shop-order/composer.json @@ -0,0 +1,12 @@ +{ + "name": "app/shop-order", + "description": "The shop order module.", + "type": "library", + "autoload": { + "psr-4": { + "Modules\\ShopOrder\\": "src/", + "Modules\\ShopOrder\\Database\\Factories\\": "database/factories/", + "Modules\\ShopOrder\\Database\\Seeders\\": "database/seeders/" + } + } +} diff --git a/tests/Fixtures/app/modules/shop-order/config/shop-order.php b/tests/Fixtures/app/modules/shop-order/config/shop-order.php new file mode 100644 index 0000000..74ba99d --- /dev/null +++ b/tests/Fixtures/app/modules/shop-order/config/shop-order.php @@ -0,0 +1,5 @@ + 'EUR', +]; diff --git a/tests/Fixtures/app/modules/shop-order/database/seeders/Deployment/ShipmentSeeder_1.php b/tests/Fixtures/app/modules/shop-order/database/seeders/Deployment/ShipmentSeeder_1.php new file mode 100644 index 0000000..8a23f65 --- /dev/null +++ b/tests/Fixtures/app/modules/shop-order/database/seeders/Deployment/ShipmentSeeder_1.php @@ -0,0 +1,13 @@ + 'Order created', +]; diff --git a/tests/Fixtures/app/modules/shop-order/routes/web/orders.php b/tests/Fixtures/app/modules/shop-order/routes/web/orders.php new file mode 100644 index 0000000..d4efdb4 --- /dev/null +++ b/tests/Fixtures/app/modules/shop-order/routes/web/orders.php @@ -0,0 +1,5 @@ + 'orders')->name('shop-order.orders'); diff --git a/tests/Fixtures/app/modules/shop-order/src/Models/Order.php b/tests/Fixtures/app/modules/shop-order/src/Models/Order.php new file mode 100644 index 0000000..5e25dd2 --- /dev/null +++ b/tests/Fixtures/app/modules/shop-order/src/Models/Order.php @@ -0,0 +1,7 @@ +extend(TestCase::class)->in('Feature'); +pest()->extend(UnitTestCase::class)->in('Unit'); +pest()->extend(PHPUnit\Framework\TestCase::class)->in('Integration'); +pest()->extend(GeneratorsTestCase::class)->in('Generators'); diff --git a/tests/TemporaryDirectory.php b/tests/TemporaryDirectory.php new file mode 100644 index 0000000..c794c43 --- /dev/null +++ b/tests/TemporaryDirectory.php @@ -0,0 +1,27 @@ +copyDirectory($copyOf, $path); + + return str_replace('\\', '/', (string) realpath($path)); + } + + public static function delete(string $path): void + { + (new Filesystem)->deleteDirectory($path); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..9dce383 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,237 @@ + + */ + private array $temporaryDirectories = []; + + protected function setUp(): void + { + $this->basePath = TemporaryDirectory::create(__DIR__.'/Fixtures/app'); + spl_autoload_register($this->autoloader = $this->autoloadModules(...)); + // Relative paths (and mutants that make paths relative) never reach the repository. + $this->workingDirectory = (string) getcwd(); + chdir($this->basePath); + + parent::setUp(); + } + + protected function tearDown(): void + { + if ($this->composerRuns !== null) { + $this->assertSame(1, $this->composerRuns, 'Composer was expected to run once.'); + } + + parent::tearDown(); + + spl_autoload_unregister($this->autoloader); + chdir($this->workingDirectory); + + unset($_ENV['APP_RUNNING_IN_CONSOLE'], $_SERVER['APP_RUNNING_IN_CONSOLE']); + ModuleRepository::flushState(); + // Static, so a package provider of one test would add its optimize task to the next ones. + ServiceProvider::$optimizeCommands = ServiceProvider::$optimizeClearCommands = []; + array_map(TemporaryDirectory::delete(...), [$this->basePath, ...$this->temporaryDirectories]); + } + + /** + * A directory outside of the application, removed after the test. + */ + protected function temporaryDirectory(?string $copyOf = null): string + { + return $this->temporaryDirectories[] = TemporaryDirectory::create($copyOf); + } + + /** + * @param Application $app + */ + protected function defineEnvironment($app): void + { + Process::fake([])->preventStrayProcesses(); + } + + protected function getApplicationBasePath(): string + { + return $this->basePath; + } + + /** + * The provider and the alias come from composer.json, as Laravel package discovery reads them. + */ + protected function getPackageProviders($app): array + { + return self::composerJson()['extra']['laravel']['providers']; + } + + protected function getPackageAliases($app): array + { + return self::composerJson()['extra']['laravel']['aliases']; + } + + /** + * Boot a fresh application, e.g. after the modules changed on disk. + */ + protected function reboot(): void + { + ModuleRepository::flushState(); + + $this->reloadApplication(); + } + + /** + * Boot a fresh application that does not run in the console, like an HTTP worker. + */ + protected function rebootForHttp(): void + { + $_ENV['APP_RUNNING_IN_CONSOLE'] = $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; + + $this->reboot(); + } + + /** + * Expect exactly one Composer run with the given arguments, in the application directory. + * + * @param list $arguments + * @param (Closure(): void)|null $during Runs as Composer would, e.g. to check the filesystem. + */ + protected function expectComposer(array $arguments, int $exitCode = 0, ?Closure $during = null): void + { + $this->composerRuns = 0; + + Process::fake(['*' => function (PendingProcess $process) use ($arguments, $exitCode, $during) { + $this->composerRuns++; + + expect($process->command)->toBe(['composer', ...$arguments]) + ->and($process->path)->toBe($this->basePath) + ->and($process->environment)->toBe(['COMPOSER_MEMORY_LIMIT' => '-1']) + ->and($process->timeout)->toBeNull(); + + if ($during !== null) { + $during(); + } + + return Process::result(exitCode: $exitCode); + }]); + } + + /** + * Wire the fixture modules into the application as "composer update" would: required, + * installed (vendor/composer/installed.json) and linked from vendor. + * + * @param list $modules + */ + protected function installModules(array $modules = ['blog', 'shop-order']): void + { + $root = ComposerJson::read($this->path('composer.json')); + $composer = new ApplicationComposer($this->basePath); + $packages = []; + + foreach ($modules as $name) { + $module = new Module( + $name, + 'app/'.$name, + 'Modules\\'.str_replace(' ', '', ucwords(str_replace('-', ' ', $name))), + $this->path('modules/'.$name), + $this->path('modules/'.$name.'/src'), + ); + $composer->addModule($root, $module, $this->path('modules')); + + $packages[] = [ + ...$this->readJson('modules/'.$name.'/composer.json'), + 'version' => 'dev-main', + 'dist' => ['type' => 'path', 'url' => 'modules/'.$name, 'reference' => null], + 'install-path' => '../app/'.$name, + ]; + + (new Filesystem)->ensureDirectoryExists($this->path('vendor/app')); + symlink('../../modules/'.$name, $this->path('vendor/app/'.$name)); + } + + $root->save(); + $this->files(['vendor/composer/installed.json' => json_encode(['packages' => $packages, 'dev' => true], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)]); + } + + /** + * @param array $files Contents by path relative to the application. + */ + protected function files(array $files): void + { + $filesystem = new Filesystem; + + foreach ($files as $path => $contents) { + $filesystem->ensureDirectoryExists(dirname($this->basePath.'/'.$path)); + $filesystem->put($this->basePath.'/'.$path, $contents); + } + } + + protected function path(string $path = ''): string + { + return $this->basePath.($path === '' ? '' : '/'.$path); + } + + /** + * @return array + */ + protected function readJson(string $path): array + { + return json_decode((string) file_get_contents($this->path($path)), true, 512, JSON_THROW_ON_ERROR); + } + + /** + * Classes of modules created by a test: Composer only knows the fixture modules. + */ + private function autoloadModules(string $class): void + { + foreach (glob($this->basePath.'/*/*/composer.json') ?: [] as $composerJson) { + $composer = json_decode((string) file_get_contents($composerJson), true); + $psr4 = [...(array) ($composer['autoload']['psr-4'] ?? []), ...(array) ($composer['autoload-dev']['psr-4'] ?? [])]; + + foreach ($psr4 as $namespace => $directory) { + $file = dirname($composerJson).'/'.$directory.'/'.str_replace('\\', '/', substr($class, strlen($namespace))).'.php'; + + if (str_starts_with($class, $namespace) && is_file($file)) { + require $file; + + return; + } + } + } + } + + /** + * @return array{extra: array{laravel: array{providers: list, aliases: array}}} + */ + private static function composerJson(): array + { + return json_decode((string) file_get_contents(__DIR__.'/../composer.json'), true, 512, JSON_THROW_ON_ERROR); + } +} diff --git a/tests/UnitTestCase.php b/tests/UnitTestCase.php new file mode 100644 index 0000000..172df1c --- /dev/null +++ b/tests/UnitTestCase.php @@ -0,0 +1,45 @@ +directory = TemporaryDirectory::create(); + // Relative paths (and mutants that make paths relative) never reach the repository. + $this->workingDirectory = (string) getcwd(); + chdir($this->directory); + } + + protected function tearDown(): void + { + chdir($this->workingDirectory); + TemporaryDirectory::delete($this->directory); + + parent::tearDown(); + } + + /** + * @param array $files Contents by path relative to the directory. + */ + protected function files(array $files): string + { + foreach ($files as $path => $contents) { + is_dir($directory = dirname($this->directory.'/'.$path)) || mkdir($directory, 0777, true); + file_put_contents($this->directory.'/'.$path, $contents); + } + + return $this->directory; + } +} From 08ebfa4ca2a0eb67d561613e5fd48692a4351596 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 09:36:54 +0300 Subject: [PATCH 09/38] Test the manifest and the scaffolding units --- tests/Unit/Manifest/ManifestBuilderTest.php | 287 ++++++++++++++ tests/Unit/Manifest/ManifestCacheTest.php | 93 +++++ .../Unit/Scaffold/ApplicationComposerTest.php | 76 ++++ tests/Unit/Scaffold/ComposerJsonTest.php | 359 ++++++++++++++++++ tests/Unit/Scaffold/InstalledPackagesTest.php | 58 +++ tests/Unit/Scaffold/NamesTest.php | 110 ++++++ tests/Unit/Scaffold/TemplateRendererTest.php | 164 ++++++++ 7 files changed, 1147 insertions(+) create mode 100644 tests/Unit/Manifest/ManifestBuilderTest.php create mode 100644 tests/Unit/Manifest/ManifestCacheTest.php create mode 100644 tests/Unit/Scaffold/ApplicationComposerTest.php create mode 100644 tests/Unit/Scaffold/ComposerJsonTest.php create mode 100644 tests/Unit/Scaffold/InstalledPackagesTest.php create mode 100644 tests/Unit/Scaffold/NamesTest.php create mode 100644 tests/Unit/Scaffold/TemplateRendererTest.php diff --git a/tests/Unit/Manifest/ManifestBuilderTest.php b/tests/Unit/Manifest/ManifestBuilderTest.php new file mode 100644 index 0000000..4fdc79b --- /dev/null +++ b/tests/Unit/Manifest/ManifestBuilderTest.php @@ -0,0 +1,287 @@ + 'routes/api', 'web' => 'routes/web'], string $commands = 'Console\\Commands'): ManifestBuilder +{ + return new ManifestBuilder($modulesPath, $routes, $commands); +} + +function moduleComposerJson(string $name, array $psr4 = ['Modules\\Demo\\' => 'src/'], array $extra = []): string +{ + return json_encode(['name' => $name, 'autoload' => ['psr-4' => $psr4], ...$extra], JSON_THROW_ON_ERROR); +} + +it('describes every module of the fixture application', function () { + $modules = realpath(FIXTURE_MODULES); + + expect(manifestBuilder($modules)->build())->toBe([ + 'blog' => [ + 'package' => 'app/blog', + 'namespace' => 'Modules\\Blog', + 'path' => $modules.'/blog', + 'src' => 'src', + 'config' => ['blog'], + 'lang' => true, + 'json' => true, + 'views' => true, + 'migrations' => true, + 'routes' => [ + 'api' => [ + '' => ['routes/api/posts.php'], + 'v1' => ['routes/api/v1/feed.php'], + 'v1/admin' => ['routes/api/v1/admin/stats.php'], + ], + 'web' => ['' => ['routes/web/pages.php']], + ], + 'seeders' => [ + '' => [ + 'Modules\\Blog\\Database\\Seeders\\BlogSeeder', + 'Modules\\Blog\\Database\\Seeders\\CategorySeeder_10', + 'Modules\\Blog\\Database\\Seeders\\PostSeeder_2', + 'Modules\\Blog\\Database\\Seeders\\SeederHelper_3', + ], + 'Deployment' => [ + 'Modules\\Blog\\Database\\Seeders\\Deployment\\BaseDeploymentSeeder_0', + 'Modules\\Blog\\Database\\Seeders\\Deployment\\DemoSeeder_1', + ], + ], + 'commands' => [ + 'Modules\\Blog\\Console\\Commands\\BlogCommand', + 'Modules\\Blog\\Console\\Commands\\PublishPostsCommand', + 'Modules\\Blog\\Console\\Commands\\helpers', + 'Modules\\Blog\\Console\\Commands\\Nested\\ArchivePostsCommand', + ], + ], + 'shop-order' => [ + 'package' => 'app/shop-order', + 'namespace' => 'Modules\\ShopOrder', + 'path' => $modules.'/shop-order', + 'src' => 'src', + 'config' => ['shop-order'], + 'lang' => true, + 'json' => false, + 'views' => false, + 'migrations' => false, + 'routes' => [ + 'web' => ['' => ['routes/web/orders.php']], + ], + 'seeders' => [ + '' => ['Modules\\ShopOrder\\Database\\Seeders\\OrderSeeder_1'], + 'Deployment' => ['Modules\\ShopOrder\\Database\\Seeders\\Deployment\\ShipmentSeeder_1'], + ], + 'commands' => [], + ], + ]); +}); + +it('finds nothing when the modules directory does not exist', function () { + expect(manifestBuilder($this->directory.'/missing')->build())->toBe([]); +}); + +it('skips directories without composer.json, files and hidden directories', function () { + $modules = $this->files([ + 'notes/README.md' => 'Not a module', + 'README.md' => 'Not a module', + '.hidden/composer.json' => moduleComposerJson('app/hidden'), + 'demo/composer.json' => moduleComposerJson('app/demo'), + ]); + + expect(array_keys(manifestBuilder($modules)->build()))->toBe(['demo']); +}); + +it('orders modules by directory name', function () { + $modules = $this->files([ + 'zeta/composer.json' => moduleComposerJson('app/zeta', ['Modules\\Zeta\\' => 'src/']), + 'alpha/composer.json' => moduleComposerJson('app/alpha', ['Modules\\Alpha\\' => 'src/']), + 'mid-dle/composer.json' => moduleComposerJson('app/mid-dle', ['Modules\\MidDle\\' => 'src/']), + ]); + + expect(array_keys(manifestBuilder($modules)->build()))->toBe(['alpha', 'mid-dle', 'zeta']); +}); + +it('uses the first psr-4 entry as the root namespace and source directory', function (array $psr4, string $namespace, string $src) { + $modules = $this->files(['demo/composer.json' => moduleComposerJson('app/demo', $psr4)]); + + $module = manifestBuilder($modules)->build()['demo']; + + expect($module['namespace'])->toBe($namespace) + ->and($module['src'])->toBe($src); +})->with([ + 'src directory' => [['Modules\\Demo\\' => 'src/'], 'Modules\\Demo', 'src'], + 'without trailing slash' => [['Modules\\Demo\\' => 'src'], 'Modules\\Demo', 'src'], + 'with ./ prefix' => [['Modules\\Demo\\' => './app'], 'Modules\\Demo', 'app'], + 'module root' => [['Modules\\Demo\\' => ''], 'Modules\\Demo', ''], + 'list of directories' => [['App\\Demo\\' => ['src/', 'lib/']], 'App\\Demo', 'src'], + 'nested directory' => [['Acme\\Shop\\Demo\\' => 'code/php/'], 'Acme\\Shop\\Demo', 'code/php'], + 'first entry wins' => [['Modules\\Demo\\' => 'src/', 'Modules\\Demo\\Database\\Factories\\' => 'database/factories/'], 'Modules\\Demo', 'src'], +]); + +it('rejects invalid modules', function (string $composerJson, string $reason) { + $modules = $this->files(['broken/composer.json' => $composerJson]); + + expect(fn () => manifestBuilder($modules)->build()) + ->toThrow(InvalidModule::class, "Invalid module [{$modules}/broken]: {$reason}"); +})->with([ + 'invalid JSON' => ['{"name": ', 'composer.json is not valid JSON'], + 'not an object' => ['"app/broken"', 'composer.json must contain an object.'], + 'no name' => ['{"autoload": {"psr-4": {"Modules\\\\Broken\\\\": "src/"}}}', 'composer.json must have a valid "name".'], + 'invalid name' => [moduleComposerJson('App/Broken'), 'composer.json must have a valid "name".'], + 'no autoload' => ['{"name": "app/broken"}', 'the first "autoload.psr-4" entry'], + 'autoload is not an object' => ['{"name": "app/broken", "autoload": "src"}', 'the first "autoload.psr-4" entry'], + 'empty psr-4' => ['{"name": "app/broken", "autoload": {"psr-4": {}}}', 'the first "autoload.psr-4" entry'], + 'no trailing backslash' => [moduleComposerJson('app/broken', ['Modules\\Broken' => 'src/']), 'the first "autoload.psr-4" entry'], + 'invalid namespace' => [moduleComposerJson('app/broken', ['Modules\\1Broken\\' => 'src/']), 'the first "autoload.psr-4" entry'], + 'invalid directory' => [moduleComposerJson('app/broken', ['Modules\\Broken\\' => 42]), 'the first "autoload.psr-4" entry'], + 'empty directory list' => [moduleComposerJson('app/broken', ['Modules\\Broken\\' => []]), 'the first "autoload.psr-4" entry'], +]); + +it('rejects two modules with the same package name', function () { + $modules = $this->files([ + 'blog/composer.json' => moduleComposerJson('app/blog', ['Modules\\Blog\\' => 'src/']), + 'blog-copy/composer.json' => moduleComposerJson('app/blog', ['Modules\\BlogCopy\\' => 'src/']), + ]); + + expect(fn () => manifestBuilder($modules)->build()) + ->toThrow(InvalidModule::class, 'the package name [app/blog] is already used by the [blog] module.'); +}); + +it('finds config files by their key, top level php files only', function () { + $modules = $this->files([ + 'demo/composer.json' => moduleComposerJson('app/demo'), + 'demo/config/demo.php' => ' ' ' '', + ]); + + expect(manifestBuilder($modules)->build()['demo']['config'])->toBe(['demo-extra', 'demo']); +}); + +it('records json translations only when the lang directory has json files', function (array $files, bool $lang, bool $json) { + $modules = $this->files(['demo/composer.json' => moduleComposerJson('app/demo'), ...$files]); + + $module = manifestBuilder($modules)->build()['demo']; + + expect($module['lang'])->toBe($lang)->and($module['json'])->toBe($json); +})->with([ + 'no lang directory' => [[], false, false], + 'php translations' => [['demo/lang/en/messages.php' => ' [['demo/lang/de.json' => '{}'], true, true], + 'json in a subdirectory only' => [['demo/lang/en/extra.json' => '{}'], true, false], +]); + +it('records views and migrations by their directories', function () { + $modules = $this->files([ + 'with/composer.json' => moduleComposerJson('app/with', ['Modules\\With\\' => 'src/']), + 'with/resources/views/.gitkeep' => '', + 'with/database/migrations/.gitkeep' => '', + 'without/composer.json' => moduleComposerJson('app/without', ['Modules\\Without\\' => 'src/']), + 'without/resources/.gitkeep' => '', + ]); + + $manifest = manifestBuilder($modules)->build(); + + expect($manifest['with']['views'])->toBeTrue() + ->and($manifest['with']['migrations'])->toBeTrue() + ->and($manifest['without']['views'])->toBeFalse() + ->and($manifest['without']['migrations'])->toBeFalse(); +}); + +it('collects route files per group, files before subdirectories, alphabetically', function () { + $modules = $this->files([ + 'demo/composer.json' => moduleComposerJson('app/demo'), + 'demo/routes/api/b.php' => ' ' '', + 'demo/routes/api/.hidden.php' => ' ' ' '', + 'demo/routes/api/v2/x.php' => ' 'build()['demo']['routes'])->toBe([ + 'api' => [ + '' => ['routes/api/a.php', 'routes/api/b.php'], + 'a' => ['routes/api/a/z.php'], + 'a/b' => ['routes/api/a/b/c.php'], + 'v2' => ['routes/api/v2/x.php'], + ], + 'web' => ['' => ['routes/web/page.php']], + ]); +}); + +it('uses the route directories of the config', function () { + $modules = $this->files([ + 'demo/composer.json' => moduleComposerJson('app/demo'), + 'demo/src/UI/API/routes/v1/posts.php' => ' ' '/src\\UI\\API/routes/'])->build()['demo']['routes']) + ->toBe(['api' => ['v1' => ['src/UI/API/routes/v1/posts.php']]]); +}); + +it('collects seeders of database/seeders and its direct subdirectories only', function () { + $modules = $this->files([ + 'demo/composer.json' => moduleComposerJson('app/demo'), + 'demo/database/seeders/RootSeeder.php' => ' ' ' ' ' '', + ]); + + expect(manifestBuilder($modules)->build()['demo']['seeders'])->toBe([ + '' => ['Modules\\Demo\\Database\\Seeders\\RootSeeder'], + 'Deployment' => ['Modules\\Demo\\Database\\Seeders\\Deployment\\DeploySeeder_1'], + ]); +}); + +it('collects commands recursively from the make:command namespace', function (string $namespace, string $directory) { + $modules = $this->files([ + 'demo/composer.json' => moduleComposerJson('app/demo', ['Modules\\Demo\\' => 'app/']), + "demo/app/{$directory}/SendCommand.php" => ' ' ' 'build()['demo']['commands'])->toBe([ + 'Modules\\Demo\\'.trim($namespace, '\\').'\\SendCommand', + 'Modules\\Demo\\'.trim($namespace, '\\').'\\Deep\\Er\\CleanCommand', + ]); +})->with([ + 'laravel' => ['Console\\Commands', 'Console/Commands'], + 'porto' => ['\\UI\\CLI\\Commands\\', 'UI/CLI/Commands'], +]); + +it('collects commands of a module whose root namespace is its directory', function () { + $modules = $this->files([ + 'demo/composer.json' => moduleComposerJson('app/demo', ['Modules\\Demo\\' => '']), + 'demo/Console/Commands/SendCommand.php' => 'build()['demo']['commands'])->toBe(['Modules\\Demo\\Console\\Commands\\SendCommand']); +}); + +it('has a key that changes with the settings', function () { + $key = manifestBuilder('/app/modules')->key(); + + expect(manifestBuilder('/app/modules/')->key())->toBe($key) + ->and(manifestBuilder('/app/other')->key())->not->toBe($key) + ->and(manifestBuilder('/app/modules', ['api' => 'routes/api'])->key())->not->toBe($key) + ->and(manifestBuilder('/app/modules', commands: 'UI\\CLI\\Commands')->key())->not->toBe($key); +}); + +it('normalizes the modules path', function () { + expect(manifestBuilder('C:\\app\\modules\\')->modulesPath())->toBe('C:/app/modules'); +}); diff --git a/tests/Unit/Manifest/ManifestCacheTest.php b/tests/Unit/Manifest/ManifestCacheTest.php new file mode 100644 index 0000000..4624e89 --- /dev/null +++ b/tests/Unit/Manifest/ManifestCacheTest.php @@ -0,0 +1,93 @@ + 'app/blog', + 'namespace' => 'Modules\\Blog', + 'path' => $path, + 'src' => 'src', + 'config' => ['blog'], + 'lang' => true, + 'json' => false, + 'views' => true, + 'migrations' => false, + 'routes' => ['api' => ['' => ['routes/api/posts.php']]], + 'seeders' => ['' => ['Modules\\Blog\\Database\\Seeders\\BlogSeeder']], + 'commands' => [], + ]; +} + +it('writes the manifest as a PHP array with paths relative to the base path', function () { + $cache = new ManifestCache(new Filesystem, $this->directory.'/bootstrap/cache/modules.php', $this->directory); + + $cache->write(['blog' => manifestEntry($this->directory.'/modules/blog')]); + + expect($cache->exists())->toBeTrue() + ->and(require $this->directory.'/bootstrap/cache/modules.php')->toBe(['blog' => manifestEntry('modules/blog')]) + ->and(file_get_contents($this->directory.'/bootstrap/cache/modules.php'))->not->toContain($this->directory); +}); + +it('reads the manifest back with absolute paths', function () { + $cache = new ManifestCache(new Filesystem, $this->directory.'/modules.php', $this->directory); + + $cache->write(['blog' => manifestEntry($this->directory.'/modules/blog')]); + + expect($cache->read())->toBe(['blog' => manifestEntry($this->directory.'/modules/blog')]); +}); + +it('works when the application is moved to another directory', function () { + (new ManifestCache(new Filesystem, $this->directory.'/build/modules.php', $this->directory.'/build')) + ->write(['blog' => manifestEntry($this->directory.'/build/modules/blog')]); + + rename($this->directory.'/build', $this->directory.'/release'); + + expect((new ManifestCache(new Filesystem, $this->directory.'/release/modules.php', $this->directory.'/release'))->read()) + ->toBe(['blog' => manifestEntry($this->directory.'/release/modules/blog')]); +}); + +it('keeps paths outside of the base path absolute', function (string $path) { + $cache = new ManifestCache(new Filesystem, $this->directory.'/modules.php', $this->directory.'/app'); + + $cache->write(['blog' => manifestEntry($path)]); + + expect((require $this->directory.'/modules.php')['blog']['path'])->toBe($path) + ->and($cache->read()['blog']['path'])->toBe($path); +})->with([ + 'unix' => ['/srv/shared/modules/blog'], + 'sibling with the same prefix' => [fn () => $this->directory.'/application/modules/blog'], + 'windows' => ['D:/shared/modules/blog'], +]); + +it('creates the cache directory', function () { + $cache = new ManifestCache(new Filesystem, $this->directory.'/missing/cache/modules.php', $this->directory); + + $cache->write([]); + + expect(require $this->directory.'/missing/cache/modules.php')->toBe([]); +}); + +it('deletes the file', function () { + $cache = new ManifestCache(new Filesystem, $this->directory.'/modules.php', $this->directory); + $cache->write([]); + + $cache->delete(); + $cache->delete(); + + expect($cache->exists())->toBeFalse() + ->and(file_exists($this->directory.'/modules.php'))->toBeFalse(); +}); + +it('normalizes a windows base path', function () { + $cache = new ManifestCache(new Filesystem, $this->directory.'/modules.php', str_replace('/', '\\', $this->directory).'\\'); + + $cache->write(['blog' => manifestEntry($this->directory.'/modules/blog')]); + + expect((require $this->directory.'/modules.php')['blog']['path'])->toBe('modules/blog') + ->and($cache->path())->toBe($this->directory.'/modules.php'); +}); diff --git a/tests/Unit/Scaffold/ApplicationComposerTest.php b/tests/Unit/Scaffold/ApplicationComposerTest.php new file mode 100644 index 0000000..602dc96 --- /dev/null +++ b/tests/Unit/Scaffold/ApplicationComposerTest.php @@ -0,0 +1,76 @@ +files(['modules/blog/composer.json' => json_encode(['name' => 'app/blog', 'autoload-dev' => ['psr-4' => $autoloadDev]])]); + + expect((new ApplicationComposer($this->directory))->autoloadDev(blogModule($this->directory)))->toBe($expected); +})->with([ + 'one path' => [['Modules\\Blog\\Tests\\' => 'tests/'], ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']], + 'dot slash' => [['Modules\\Blog\\Tests\\' => './tests/'], ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']], + 'several paths' => [['Modules\\Blog\\Tests\\' => ['tests/', 'fixtures/']], ['Modules\\Blog\\Tests\\' => ['modules/blog/tests/', 'modules/blog/fixtures/']]], + 'one path in a list' => [['Modules\\Blog\\Tests\\' => ['tests/']], ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']], + 'invalid paths' => [['Modules\\Blog\\Tests\\' => [1, null, 'tests/'], 'Empty\\' => [], 'Number\\' => 5], ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']], + 'nothing' => [[], []], +]); + +it('reads no dev autoload from a module without it', function () { + $this->files(['modules/blog/composer.json' => '{"name": "app/blog"}']); + + expect((new ApplicationComposer($this->directory))->autoloadDev(blogModule($this->directory)))->toBe([]); +}); + +it('adds a module and tells whether composer.json changed', function () { + $this->files([ + 'composer.json' => '{"name": "demo/app"}', + 'modules/blog/composer.json' => '{"name": "app/blog", "autoload-dev": {"psr-4": {"Modules\\\\Blog\\\\Tests\\\\": "tests/"}}}', + ]); + $composer = new ApplicationComposer($this->directory.'/'); + $root = $composer->composerJson(); + + expect($composer->addModule($root, blogModule($this->directory), $this->directory.'/modules'))->toBeTrue() + ->and($composer->addModule($root, blogModule($this->directory), $this->directory.'/modules'))->toBeFalse() + ->and($root->toArray())->toMatchArray([ + 'require' => ['app/blog' => '*@dev'], + 'autoload-dev' => ['psr-4' => ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']], + ]) + ->and($root->get('repositories.0.url'))->toBe('modules/*') + ->and($composer->composerJsonPath())->toBe($this->directory.'/composer.json'); +}); + +it('makes paths inside the application relative', function (string $path, string $expected) { + $composer = new ApplicationComposer($this->directory); + + expect($composer->relative(str_replace('{base}', $this->directory, $path)))->toBe(str_replace('{base}', $this->directory, $expected)); +})->with([ + 'inside' => ['{base}/modules/blog', 'modules/blog'], + 'trailing slash' => ['{base}/modules/', 'modules'], + 'the application' => ['{base}', ''], + 'a sibling with the same prefix' => ['{base}-other/modules', '{base}-other/modules'], + 'outside' => ['/elsewhere/modules', '/elsewhere/modules'], +]); + +it('reads the installed packages of the vendor directory', function (?string $vendorDir, string $installed) { + $this->files([ + 'composer.json' => json_encode($vendorDir === null ? ['name' => 'demo/app'] : ['config' => ['vendor-dir' => str_replace('{base}', $this->directory, $vendorDir)]]), + $installed.'/composer/installed.json' => '{"packages": [{"name": "app/blog"}]}', + ]); + $composer = new ApplicationComposer($this->directory); + $packages = $composer->installed($composer->composerJson()); + + expect($packages->has('app/blog'))->toBeTrue() + ->and($packages->path('app/blog'))->toBe($this->directory.'/'.$installed.'/app/blog'); +})->with([ + 'default' => [null, 'vendor'], + 'relative' => ['libraries', 'libraries'], + 'absolute' => ['{base}/deps', 'deps'], +]); diff --git a/tests/Unit/Scaffold/ComposerJsonTest.php b/tests/Unit/Scaffold/ComposerJsonTest.php new file mode 100644 index 0000000..51b0af8 --- /dev/null +++ b/tests/Unit/Scaffold/ComposerJsonTest.php @@ -0,0 +1,359 @@ + 'path', 'url' => 'modules/*', 'options' => ['symlink' => true]]; + +const PACKAGIST_EXCLUDING_APP = ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['app/*']]; + +/** + * @param array|string $contents + * @return array + */ +function editComposerJson(string $directory, array|string $contents, Closure $edit): array +{ + $path = $directory.'/composer.json'; + file_put_contents($path, is_string($contents) ? $contents : json_encode($contents, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + $composerJson = ComposerJson::read($path); + $edit($composerJson); + $composerJson->save(); + + return json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); +} + +function addBlog(ComposerJson $composerJson): void +{ + $composerJson->addModule('app/blog', 'modules/*', ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']); +} + +it('adds a module: path repository, requirement, dev autoload and the Packagist filter', function () { + $json = editComposerJson($this->directory, ['name' => 'demo/app', 'require' => ['php' => '^8.3']], addBlog(...)); + + expect($json)->toBe([ + 'name' => 'demo/app', + 'require' => ['php' => '^8.3', 'app/blog' => '*@dev'], + 'repositories' => [PATH_REPOSITORY, PACKAGIST_EXCLUDING_APP, ['packagist.org' => false]], + 'autoload-dev' => ['psr-4' => ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']], + ]); +}); + +it('is idempotent', function () { + $once = editComposerJson($this->directory, ['name' => 'demo/app'], addBlog(...)); + $twice = editComposerJson($this->directory, $once, addBlog(...)); + + expect($twice)->toBe($once); +}); + +it('adds more modules to the same repository and filter', function () { + $json = editComposerJson($this->directory, ['name' => 'demo/app'], function (ComposerJson $composerJson): void { + addBlog($composerJson); + $composerJson->addModule('app/shop', 'modules/*', []); + $composerJson->addModule('acme/billing', 'modules/*', []); + }); + + expect($json['require'])->toBe(['app/blog' => '*@dev', 'app/shop' => '*@dev', 'acme/billing' => '*@dev']) + ->and($json['repositories'])->toBe([ + PATH_REPOSITORY, + ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['app/*', 'acme/*']], + ['packagist.org' => false], + ]); +}); + +it('keeps an existing requirement of the module', function () { + $json = editComposerJson($this->directory, ['require' => ['app/blog' => '^1.0']], addBlog(...)); + + expect($json['require'])->toBe(['app/blog' => '^1.0']); +}); + +it('sorts the requirements when composer sorts packages', function () { + $json = editComposerJson($this->directory, [ + 'require' => ['php' => '^8.3', 'laravel/framework' => '^13.0', 'ext-json' => '*'], + 'config' => ['sort-packages' => true], + ], addBlog(...)); + + expect(array_keys($json['require']))->toBe(['php', 'ext-json', 'app/blog', 'laravel/framework']); +}); + +it('sorts the requirements like Composer', function () { + $json = editComposerJson($this->directory, [ + 'require' => array_fill_keys(['zeta/pkg', 'composer-runtime-api', 'lib-pcre', 'ext-mbstring', 'hhvm', 'php-64bit', 'ext-json', 'php', 'ext-10', 'app/wiki'], '*'), + 'config' => ['sort-packages' => true], + ], addBlog(...)); + + expect(array_keys($json['require']))->toBe([ + 'php', 'php-64bit', 'hhvm', 'ext-10', 'ext-json', 'ext-mbstring', 'lib-pcre', 'composer-runtime-api', 'app/blog', 'app/wiki', 'zeta/pkg', + ]); +}); + +it('adds a new requirement last when composer does not sort packages', function () { + $json = editComposerJson($this->directory, ['require' => ['php' => '^8.3', 'laravel/framework' => '^13.0']], addBlog(...)); + + expect(array_keys($json['require']))->toBe(['php', 'laravel/framework', 'app/blog']); +}); + +it('reuses an existing path repository', function () { + $json = editComposerJson($this->directory, [ + 'repositories' => [['type' => 'path', 'url' => 'modules/*']], + ], addBlog(...)); + + expect($json['repositories'])->toBe([['type' => 'path', 'url' => 'modules/*'], PACKAGIST_EXCLUDING_APP, ['packagist.org' => false]]); +}); + +it('adds the path repository when others point elsewhere', function () { + $json = editComposerJson($this->directory, [ + 'repositories' => [['type' => 'path', 'url' => 'packages/*'], ['type' => 'vcs', 'url' => 'modules/*'], ['packagist.org' => false]], + ], addBlog(...)); + + expect($json['repositories'])->toBe([ + ['type' => 'path', 'url' => 'packages/*'], + ['type' => 'vcs', 'url' => 'modules/*'], + ['packagist.org' => false], + PATH_REPOSITORY, + ]); +}); + +it('extends an existing packagist repository', function (string $url) { + $json = editComposerJson($this->directory, [ + 'repositories' => [['type' => 'composer', 'url' => $url, 'exclude' => ['other/*']], ['packagist.org' => false]], + ], addBlog(...)); + + expect($json['repositories'])->toBe([ + ['type' => 'composer', 'url' => $url, 'exclude' => ['other/*', 'app/*']], + ['packagist.org' => false], + PATH_REPOSITORY, + ]); +})->with(['https://repo.packagist.org', 'https://repo.packagist.org/', 'https://packagist.org', 'http://repo.packagist.org/packages']); + +it('does not disable the default packagist repository again when another one replaces it', function () { + // Composer disables the default repository when a repository has a packagist.org URL. + $json = editComposerJson($this->directory, ['repositories' => [['type' => 'composer', 'url' => 'https://repo.packagist.org']]], addBlog(...)); + + expect($json['repositories'])->toBe([PACKAGIST_EXCLUDING_APP, PATH_REPOSITORY]); +}); + +it('does not take a mirror for packagist', function () { + $mirror = ['type' => 'composer', 'url' => 'https://packagist.org.mirror.example.com']; + + $json = editComposerJson($this->directory, ['repositories' => [$mirror]], addBlog(...)); + + expect($json['repositories'])->toBe([$mirror, PATH_REPOSITORY, PACKAGIST_EXCLUDING_APP, ['packagist.org' => false]]); +}); + +it('leaves a packagist repository restricted with "only" untouched', function () { + $repository = ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'only' => ['laravel/*']]; + + $json = editComposerJson($this->directory, ['repositories' => [$repository, ['packagist.org' => false]]], addBlog(...)); + + expect($json['repositories'])->toBe([$repository, ['packagist.org' => false], PATH_REPOSITORY]); +}); + +it('does not enable packagist when it is disabled', function (array $disabled) { + $json = editComposerJson($this->directory, ['repositories' => [$disabled]], addBlog(...)); + + expect($json['repositories'])->toBe([$disabled, PATH_REPOSITORY]); +})->with([ + [['packagist.org' => false]], + [['packagist' => false]], +]); + +it('supports repositories defined as an object', function () { + $json = editComposerJson($this->directory, [ + 'repositories' => ['private' => ['type' => 'composer', 'url' => 'https://repo.example.com'], 'modules' => ['type' => 'vcs', 'url' => 'x']], + ], addBlog(...)); + + expect($json['repositories'])->toBe([ + 'private' => ['type' => 'composer', 'url' => 'https://repo.example.com'], + 'modules' => ['type' => 'vcs', 'url' => 'x'], + 'modules-2' => PATH_REPOSITORY, + 'packagist' => PACKAGIST_EXCLUDING_APP, + 'packagist.org' => false, + ]); +}); + +it('does not enable packagist when it is disabled in repositories defined as an object', function () { + $json = editComposerJson($this->directory, ['repositories' => ['packagist.org' => false]], addBlog(...)); + + expect($json['repositories'])->toBe(['packagist.org' => false, 'modules' => PATH_REPOSITORY]); +}); + +it('ignores repository entries that are not objects', function () { + $json = editComposerJson($this->directory, ['repositories' => ['modules/*', ['type' => 'path']]], addBlog(...)); + + expect($json['repositories'])->toBe(['modules/*', ['type' => 'path'], PATH_REPOSITORY, PACKAGIST_EXCLUDING_APP, ['packagist.org' => false]]); +}); + +it('keeps the formatting: empty objects, indentation, unicode and slashes', function () { + $original = "{\n \"name\": \"demo/app\",\n \"description\": \"Приложение / app\",\n \"require\": {},\n \"extra\": {},\n \"scripts\": {\n \"test\": [\"pest\"]\n }\n}\n"; + + editComposerJson($this->directory, $original, addBlog(...)); + + expect(file_get_contents($this->directory.'/composer.json'))->toBe(<<<'JSON' + { + "name": "demo/app", + "description": "Приложение / app", + "require": { + "app/blog": "*@dev" + }, + "extra": {}, + "scripts": { + "test": [ + "pest" + ] + }, + "repositories": [ + { + "type": "path", + "url": "modules/*", + "options": { + "symlink": true + } + }, + { + "type": "composer", + "url": "https://repo.packagist.org", + "exclude": [ + "app/*" + ] + }, + { + "packagist.org": false + } + ], + "autoload-dev": { + "psr-4": { + "Modules\\Blog\\Tests\\": "modules/blog/tests/" + } + } + } + + JSON); +}); + +it('keeps tab indentation', function () { + editComposerJson($this->directory, "{\n\t\"name\": \"demo/app\",\n\t\"extra\": {\n\t\t\"a\": 1\n\t}\n}", fn (ComposerJson $json) => $json->addProvider('X')); + + expect(file_get_contents($this->directory.'/composer.json')) + ->toBe("{\n\t\"name\": \"demo/app\",\n\t\"extra\": {\n\t\t\"a\": 1,\n\t\t\"laravel\": {\n\t\t\t\"providers\": [\n\t\t\t\t\"X\"\n\t\t\t]\n\t\t}\n\t}\n}\n"); +}); + +it('does not write the file when nothing changed', function () { + $original = '{"name":"demo/app","require":{"app/blog":"*"},"repositories":[{"type":"path","url":"modules/*"},{"packagist.org":false}]}'; + file_put_contents($this->directory.'/composer.json', $original); + + $composerJson = ComposerJson::read($this->directory.'/composer.json'); + $composerJson->addModule('app/blog', 'modules/*', []); + + expect($composerJson->isDirty())->toBeFalse(); + + $composerJson->save(); + + expect(file_get_contents($this->directory.'/composer.json'))->toBe($original); +}); + +it('replaces the file atomically', function () { + editComposerJson($this->directory, ['name' => 'demo/app'], addBlog(...)); + + expect(scandir($this->directory))->toBe(['.', '..', 'composer.json']); +}); + +it('saves again after more changes', function () { + file_put_contents($this->directory.'/composer.json', '{}'); + $composerJson = ComposerJson::read($this->directory.'/composer.json'); + + $composerJson->addProvider('A')->save(); + expect($composerJson->isDirty())->toBeFalse(); + + $composerJson->addProvider('B')->save(); + + expect(json_decode((string) file_get_contents($this->directory.'/composer.json'), true)) + ->toBe(['extra' => ['laravel' => ['providers' => ['A', 'B']]]]); +}); + +it('removes a requirement and dev autoload namespaces', function () { + $json = editComposerJson($this->directory, [ + 'require' => ['php' => '^8.3', 'app/blog' => '*'], + 'autoload-dev' => ['psr-4' => ['Tests\\' => 'tests/', 'Modules\\Blog\\Tests\\' => 'modules/blog/tests/']], + ], function (ComposerJson $composerJson): void { + $composerJson->removeRequire('app/blog')->removeRequire('app/missing'); + $composerJson->removeAutoloadDev(['Modules\\Blog\\Tests\\', 'Missing\\']); + }); + + expect($json)->toBe([ + 'require' => ['php' => '^8.3'], + 'autoload-dev' => ['psr-4' => ['Tests\\' => 'tests/']], + ]); +}); + +it('removes nothing when there is nothing to remove', function () { + $json = editComposerJson($this->directory, ['name' => 'demo/app'], function (ComposerJson $composerJson): void { + $composerJson->removeRequire('app/blog')->removeAutoloadDev(['Modules\\Blog\\Tests\\']); + }); + + expect($json)->toBe(['name' => 'demo/app']); +}); + +it('adds a provider once', function () { + $json = editComposerJson($this->directory, [ + 'extra' => ['laravel' => ['providers' => ['Modules\\Blog\\Providers\\EventServiceProvider'], 'aliases' => []]], + ], function (ComposerJson $composerJson): void { + $composerJson->addProvider('Modules\\Blog\\Providers\\BlogServiceProvider'); + $composerJson->addProvider('Modules\\Blog\\Providers\\BlogServiceProvider'); + }); + + expect($json['extra']['laravel'])->toBe([ + 'providers' => ['Modules\\Blog\\Providers\\EventServiceProvider', 'Modules\\Blog\\Providers\\BlogServiceProvider'], + 'aliases' => [], + ]); +}); + +it('reads values', function () { + file_put_contents($this->directory.'/composer.json', '{"name":"demo/app","require":{"app/blog":"*","vendor/pkg.name":"^1"},"autoload-dev":{"psr-4":{"A\\\\":"a/"}}}'); + + $composerJson = ComposerJson::read($this->directory.'/composer.json'); + + expect($composerJson->get('name'))->toBe('demo/app') + ->and($composerJson->get('autoload-dev.psr-4'))->toBe(['A\\' => 'a/']) + ->and($composerJson->get('missing'))->toBeNull() + ->and($composerJson->requires('app/blog'))->toBeTrue() + ->and($composerJson->requires('vendor/pkg.name'))->toBeTrue() + ->and($composerJson->requires('app/shop'))->toBeFalse() + ->and($composerJson->toArray()['require'])->toBe(['app/blog' => '*', 'vendor/pkg.name' => '^1']); +}); + +it('tells whether packagist can serve a package', function (array $repositories, bool $excluded) { + file_put_contents($this->directory.'/composer.json', json_encode(['repositories' => $repositories])); + + expect(ComposerJson::read($this->directory.'/composer.json')->isExcludedFromPackagist('app/blog'))->toBe($excluded); +})->with([ + 'default packagist' => [[], false], + 'other repositories only' => [[['type' => 'path', 'url' => 'modules/*']], false], + 'excluded vendor' => [[PACKAGIST_EXCLUDING_APP, ['packagist.org' => false]], true], + 'excluded package' => [[['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['app/blog']], ['packagist.org' => false]], true], + 'excluded pattern with uppercase' => [[['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['APP/*']], ['packagist.org' => false]], true], + 'other vendor excluded' => [[['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['acme/*']], ['packagist.org' => false]], false], + 'similar vendor excluded' => [[['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['ap/*']], ['packagist.org' => false]], false], + 'only other vendors' => [[['type' => 'composer', 'url' => 'https://repo.packagist.org', 'only' => ['laravel/*']], ['packagist.org' => false]], true], + 'only the vendor' => [[['type' => 'composer', 'url' => 'https://repo.packagist.org', 'only' => ['app/*']], ['packagist.org' => false]], false], + 'disabled' => [[['packagist.org' => false]], true], + 'disabled by name' => [['packagist.org' => false], true], +]); + +it('fails on files it can not edit', function (?string $contents, string $message) { + $path = $this->directory.'/composer.json'; + + if ($contents !== null) { + file_put_contents($path, $contents); + } + + expect(fn () => ComposerJson::read($path)->addModule('app/blog', 'modules/*', []))->toThrow(ComposerFailed::class, $message); +})->with([ + 'missing file' => [null, 'Unable to read'], + 'invalid JSON' => ['{', 'is not valid JSON'], + 'not an object' => ['[]', 'must contain a JSON object'], + 'require is a list' => ['{"require": ["app/blog"]}', '"require" must be an object'], + 'repositories is a string' => ['{"repositories": "modules/*"}', '"repositories" must be an array or an object'], +]); diff --git a/tests/Unit/Scaffold/InstalledPackagesTest.php b/tests/Unit/Scaffold/InstalledPackagesTest.php new file mode 100644 index 0000000..9169c9e --- /dev/null +++ b/tests/Unit/Scaffold/InstalledPackagesTest.php @@ -0,0 +1,58 @@ + 'app/blog', + 'version' => 'dev-main', + 'dist' => ['type' => 'path', 'url' => 'modules/blog'], + 'require' => ['php' => '^8.3'], + 'type' => 'library', + 'extra' => ['laravel' => ['providers' => ['Modules\\Blog\\BlogServiceProvider']]], + 'autoload' => ['psr-4' => ['Modules\\Blog\\' => 'src/']], +]; + +it('reads the packages of composer 2', function () { + $vendor = $this->files(['composer/installed.json' => json_encode(['packages' => [INSTALLED_BLOG], 'dev' => true])]); + + $installed = InstalledPackages::read($vendor); + + expect($installed->has('app/blog'))->toBeTrue() + ->and($installed->has('app/shop'))->toBeFalse() + ->and($installed->path('app/blog'))->toBe($vendor.'/app/blog'); +}); + +it('reads the packages of composer 1', function () { + $vendor = $this->files(['composer/installed.json' => json_encode([INSTALLED_BLOG])]); + + expect(InstalledPackages::read($vendor)->has('app/blog'))->toBeTrue(); +}); + +it('has no packages without installed.json', function (?string $contents) { + if ($contents !== null) { + $this->files(['composer/installed.json' => $contents]); + } + + expect(InstalledPackages::read($this->directory)->has('app/blog'))->toBeFalse(); +})->with([ + 'missing' => [null], + 'invalid' => ['{'], + 'unexpected' => ['{"packages": [42, {"version": "1.0"}]}'], +]); + +it('compares the installed metadata with composer.json', function (array $composerJson, bool $current) { + $installed = InstalledPackages::read($this->files(['composer/installed.json' => json_encode(['packages' => [INSTALLED_BLOG]])])); + + expect($installed->isCurrent('app/blog', $composerJson))->toBe($current); +})->with([ + 'same' => [['name' => 'app/blog', 'require' => ['php' => '^8.3'], 'extra' => INSTALLED_BLOG['extra'], 'autoload' => INSTALLED_BLOG['autoload'], 'description' => 'changed'], true], + 'new provider' => [['require' => ['php' => '^8.3'], 'extra' => ['laravel' => ['providers' => ['A', 'B']]], 'autoload' => INSTALLED_BLOG['autoload']], false], + 'new namespace' => [['require' => ['php' => '^8.3'], 'extra' => INSTALLED_BLOG['extra'], 'autoload' => ['psr-4' => ['Modules\\Blog\\' => 'src/', 'X\\' => 'x/']]], false], + 'new dependency' => [['require' => ['php' => '^8.3', 'acme/lib' => '^1'], 'extra' => INSTALLED_BLOG['extra'], 'autoload' => INSTALLED_BLOG['autoload']], false], +]); + +it('is not current when the package is not installed', function () { + expect(InstalledPackages::read($this->directory)->isCurrent('app/blog', []))->toBeFalse(); +}); diff --git a/tests/Unit/Scaffold/NamesTest.php b/tests/Unit/Scaffold/NamesTest.php new file mode 100644 index 0000000..4df5f50 --- /dev/null +++ b/tests/Unit/Scaffold/NamesTest.php @@ -0,0 +1,110 @@ +toBe($module); +})->with([ + ['blog', 'blog'], + ['Blog', 'blog'], + ['shop-order', 'shop-order'], + ['ShopOrder', 'shop-order'], + ['shopOrder', 'shop-order'], + ['shop_order', 'shop-order'], + ['shop order', 'shop-order'], + [' blog ', 'blog'], + ['api-v2', 'api-v2'], + ['blog2', 'blog2'], + ['list', 'list'], + 'leading dash' => ['-blog', 'blog'], + 'trailing nul byte' => ["blog\0", 'blog'], +]); + +it('rejects module names that are not safe', function (string $name) { + expect(fn () => Names::module($name))->toThrow(InvalidName::class, 'Invalid module name'); +})->with([ + '', + ' ', + '../etc', + '..', + 'blog/../../etc', + 'blog\\evil', + '2fa', + 'blog"', + "blog'", + 'bl$og', + 'блог', + "bl\0og", + 'a/b', +]); + +it('validates vendors', function (string $vendor, bool $valid) { + $validate = fn () => Names::vendor($vendor); + + $valid ? expect($validate())->toBe($vendor) : expect($validate)->toThrow(InvalidName::class, 'Invalid vendor'); +})->with([ + ['app', true], + ['acme-corp', true], + ['acme.corp', true], + ['acme_corp', true], + ['2acme', true], + ['App', false], + ['-app', false], + ['app-', false], + ['a/b', false], + ['', false], + ['app"', false], + ['app,"evil":"1', false], +]); + +it('builds package names', function () { + expect(Names::package('app', 'ShopOrder'))->toBe('app/shop-order'); +}); + +it('validates package names with the Composer format', function (string $package, bool $valid) { + expect(Names::isPackage($package))->toBe($valid); + + $valid + ? expect(Names::assertPackage($package))->toBe($package) + : expect(fn () => Names::assertPackage($package))->toThrow(InvalidName::class, 'Invalid package name'); +})->with([ + ['app/blog', true], + ['app/shop-order', true], + ['acme.corp/shop--order', true], + ['app/blog_2', true], + ['app', false], + ['App/blog', false], + ['app/-blog', false], + ['app/blog/extra', false], + ['--dev', false], + ['-app/blog', false], + ["app/blog\n", false], + ['app/blog ', false], +]); + +it('builds the namespace of a module', function (string $prefix, string $module, string $namespace) { + expect(Names::namespace($prefix, $module))->toBe($namespace); +})->with([ + ['Modules', 'blog', 'Modules\\Blog'], + ['Modules', 'shop-order', 'Modules\\ShopOrder'], + ['\\App\\Modules\\', 'shop-order', 'App\\Modules\\ShopOrder'], +]); + +it('rejects an invalid namespace prefix', function (string $prefix) { + expect(fn () => Names::namespace($prefix, 'blog'))->toThrow(InvalidName::class, 'Invalid namespace'); +})->with(['', 'App\\\\Modules', '1Modules', 'App-Modules', 'App\\Modules;']); + +it('validates namespaces', function (string $namespace, bool $valid) { + expect(Names::isNamespace($namespace))->toBe($valid); +})->with([ + ['Modules', true], + ['Modules\\Blog', true], + ['_Modules\\Blog_2', true], + ['Modules\\', false], + ['\\Modules', false], + ['Modules\\2Blog', false], + ['', false], +]); diff --git a/tests/Unit/Scaffold/TemplateRendererTest.php b/tests/Unit/Scaffold/TemplateRendererTest.php new file mode 100644 index 0000000..b81eaee --- /dev/null +++ b/tests/Unit/Scaffold/TemplateRendererTest.php @@ -0,0 +1,164 @@ + 'article-category', + 'namespace' => 'Modules\\ArticleCategory', + 'package' => 'app/article-category', + 'vendor' => 'app', + 'date' => '2026_09_23_120000', +]; + +it('renders stubs, copies other files and keeps empty directories', function () { + $template = $this->files([ + 'composer.json.stub' => '{"name": "{{ package }}", "psr-4": "{{ namespace|json }}\\\\"}', + 'src/Models/.gitkeep' => '', + 'resources/views/{{ name }}.blade.php' => '{{ $title }} and {{ name }} stay as they are', + 'README.md.stub' => "# {{name}}\n", + ]); + + expect((new TemplateRenderer)->render($template, TEMPLATE_VARIABLES))->toBe([ + 'README.md' => "# article-category\n", + 'composer.json' => '{"name": "app/article-category", "psr-4": "Modules\\\\ArticleCategory\\\\"}', + 'resources/views/article-category.blade.php' => '{{ $title }} and {{ name }} stay as they are', + 'src/Models/.gitkeep' => '', + ]); +}); + +it('applies filters from left to right', function (string $placeholder, string $result) { + $template = $this->files(['file.stub' => $placeholder]); + + expect((new TemplateRenderer)->render($template, TEMPLATE_VARIABLES)['file'])->toBe($result); +})->with([ + ['{{ name }}', 'article-category'], + ['{{ name|studly }}', 'ArticleCategory'], + ['{{ name|camel }}', 'articleCategory'], + ['{{ name|snake }}', 'article_category'], + ['{{ name|kebab }}', 'article-category'], + ['{{ name|studly|kebab }}', 'article-category'], + ['{{ name|plural }}', 'article-categories'], + ['{{ name|plural|snake }}', 'article_categories'], + ['{{ name|plural|studly }}', 'ArticleCategories'], + ['{{ name|studly|singular }}', 'ArticleCategory'], + ['{{ name|upper }}', 'ARTICLE-CATEGORY'], + ['{{ name|studly|lower }}', 'articlecategory'], + ['{{ name|title }}', 'Article-Category'], + ['{{ namespace|json }}', 'Modules\\\\ArticleCategory'], + ['{{ package|json }}', 'app/article-category'], + ['{{ name | studly }}', 'ArticleCategory'], + ['{{name|studly}}', 'ArticleCategory'], + ['{{ date }}_create_{{ name|plural|snake }}_table', '2026_09_23_120000_create_article_categories_table'], + ['{{ vendor }}/{{ name }}', 'app/article-category'], + ['{{ missing', '{{ missing'], + ['{ name }', '{ name }'], +]); + +it('renders placeholders in directory and file names', function () { + $template = $this->files([ + 'src/{{ name|studly }}/{{ name|studly }}Service.php.stub' => 'namespace {{ namespace }}\\{{ name|studly }};', + 'database/migrations/{{ date }}_create_{{ name|plural|snake }}_table.php' => 'render($template, TEMPLATE_VARIABLES))->toBe([ + 'database/migrations/2026_09_23_120000_create_article_categories_table.php' => ' 'namespace Modules\\ArticleCategory\\ArticleCategory;', + ]); +}); + +it('fails on unknown variables and filters before anything is written', function (string $file, string $contents, string $reason) { + $template = $this->files([$file => $contents]); + + expect(fn () => (new TemplateRenderer)->render($template, TEMPLATE_VARIABLES)) + ->toThrow(InvalidTemplate::class, "Invalid module template [{$file}]: {$reason}"); +})->with([ + 'unknown variable' => ['a.stub', '{{ model }}', 'unknown variable [model].'], + 'unknown filter' => ['a.stub', '{{ name|reverse }}', 'unknown filter [reverse].'], + 'unknown variable in a path' => ['{{ model }}.php', '', 'unknown variable [model].'], + 'unknown filter in a path' => ['{{ name|slug }}.php', '', 'unknown filter [slug].'], +]); + +it('does not render placeholders in the contents of files without .stub', function () { + $template = $this->files(['view.blade.php' => '{{ model }}']); + + expect((new TemplateRenderer)->render($template, TEMPLATE_VARIABLES))->toBe(['view.blade.php' => '{{ model }}']); +}); + +it('refuses paths that would leave the module directory', function (array $variables, string $file) { + $template = $this->files([$file => '']); + + expect(fn () => (new TemplateRenderer)->render($template, [...TEMPLATE_VARIABLES, ...$variables])) + ->toThrow(InvalidTemplate::class, 'the path renders to an unsafe name'); +})->with([ + 'parent directory' => [['name' => '..'], '{{ name }}/evil.php'], + 'current directory' => [['name' => '.'], '{{ name }}/file.php'], + 'empty segment' => [['name' => ''], '{{ name }}/file.php'], + 'slash' => [['name' => '../../etc'], '{{ name }}.php'], + 'backslash' => [[], '{{ namespace }}.php'], + 'drive letter' => [['name' => 'C:'], '{{ name }}/file.php'], + 'nul byte' => [['name' => "evil\0"], '{{ name }}.php'], +]); + +it('refuses two files rendered to the same path', function () { + $template = $this->files([ + 'composer.json' => '{}', + 'composer.json.stub' => '{}', + ]); + + expect(fn () => (new TemplateRenderer)->render($template, TEMPLATE_VARIABLES)) + ->toThrow(InvalidTemplate::class, 'another file is also rendered to [composer.json].'); +}); + +it('refuses a path rendered both as a file and as a directory', function () { + $template = $this->files([ + 'composer.json' => '{}', + '{{ name }}.stub' => '', + 'article-category/src/file.php' => '', + ]); + + expect(fn () => (new TemplateRenderer)->render($template, TEMPLATE_VARIABLES)) + ->toThrow(InvalidTemplate::class, 'Invalid module template [{{ name }}.stub]: the file is rendered to [article-category], which is also the directory of [article-category/src/file.php].'); +}); + +it('accepts a template path with a trailing slash', function () { + $template = $this->files(['README.md.stub' => '{{ name }}']); + + expect((new TemplateRenderer)->render($template.'/', TEMPLATE_VARIABLES))->toBe(['README.md' => 'article-category']); +}); + +it('fails when the template directory does not exist', function () { + expect(fn () => (new TemplateRenderer)->render($this->directory.'/missing', TEMPLATE_VARIABLES)) + ->toThrow(InvalidTemplate::class, 'the directory does not exist.'); +}); + +it('renders the built-in template', function () { + $files = (new TemplateRenderer)->render(__DIR__.'/../../../resources/stubs/module/default', TEMPLATE_VARIABLES); + + expect(array_keys($files))->toBe([ + 'composer.json', + 'database/factories/.gitkeep', + 'database/migrations/.gitkeep', + 'database/seeders/.gitkeep', + 'src/Models/.gitkeep', + 'tests/.gitkeep', + ])->and(json_decode($files['composer.json'], true, 512, JSON_THROW_ON_ERROR))->toBe([ + 'name' => 'app/article-category', + 'description' => 'The article-category module.', + 'type' => 'library', + 'autoload' => [ + 'psr-4' => [ + 'Modules\\ArticleCategory\\' => 'src/', + 'Modules\\ArticleCategory\\Database\\Factories\\' => 'database/factories/', + 'Modules\\ArticleCategory\\Database\\Seeders\\' => 'database/seeders/', + ], + ], + 'autoload-dev' => [ + 'psr-4' => [ + 'Modules\\ArticleCategory\\Tests\\' => 'tests/', + ], + ], + ]); +}); From 1b6aadd25d7742604d1af8d11e26142e2f6e89c2 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 09:36:54 +0300 Subject: [PATCH 10/38] Test the conventions, the discovery and the commands --- tests/Feature/CacheTest.php | 65 ++++++ tests/Feature/Commands/ComposerRunnerTest.php | 42 ++++ tests/Feature/Commands/DeleteCommandTest.php | 147 ++++++++++++ tests/Feature/Commands/DoctorCommandTest.php | 145 ++++++++++++ tests/Feature/Commands/ListCommandTest.php | 47 ++++ tests/Feature/Commands/MakeCommandTest.php | 212 ++++++++++++++++++ tests/Feature/Commands/SyncCommandTest.php | 141 ++++++++++++ tests/Feature/Conventions/ConfigTest.php | 51 +++++ tests/Feature/Conventions/MigrationsTest.php | 21 ++ tests/Feature/Conventions/RoutesTest.php | 80 +++++++ .../Feature/Conventions/TranslationsTest.php | 49 ++++ tests/Feature/Conventions/ViewsTest.php | 40 ++++ tests/Feature/Discovery/CommandsTest.php | 66 ++++++ tests/Feature/Discovery/FactoriesTest.php | 86 +++++++ tests/Feature/Discovery/OctaneWatchTest.php | 48 ++++ tests/Feature/Discovery/TinkerTest.php | 21 ++ tests/Feature/ModuleRepositoryTest.php | 153 +++++++++++++ tests/Feature/ProviderTest.php | 132 +++++++++++ 18 files changed, 1546 insertions(+) create mode 100644 tests/Feature/CacheTest.php create mode 100644 tests/Feature/Commands/ComposerRunnerTest.php create mode 100644 tests/Feature/Commands/DeleteCommandTest.php create mode 100644 tests/Feature/Commands/DoctorCommandTest.php create mode 100644 tests/Feature/Commands/ListCommandTest.php create mode 100644 tests/Feature/Commands/MakeCommandTest.php create mode 100644 tests/Feature/Commands/SyncCommandTest.php create mode 100644 tests/Feature/Conventions/ConfigTest.php create mode 100644 tests/Feature/Conventions/MigrationsTest.php create mode 100644 tests/Feature/Conventions/RoutesTest.php create mode 100644 tests/Feature/Conventions/TranslationsTest.php create mode 100644 tests/Feature/Conventions/ViewsTest.php create mode 100644 tests/Feature/Discovery/CommandsTest.php create mode 100644 tests/Feature/Discovery/FactoriesTest.php create mode 100644 tests/Feature/Discovery/OctaneWatchTest.php create mode 100644 tests/Feature/Discovery/TinkerTest.php create mode 100644 tests/Feature/ModuleRepositoryTest.php create mode 100644 tests/Feature/ProviderTest.php diff --git a/tests/Feature/CacheTest.php b/tests/Feature/CacheTest.php new file mode 100644 index 0000000..0e5e418 --- /dev/null +++ b/tests/Feature/CacheTest.php @@ -0,0 +1,65 @@ +artisan('module:cache')->expectsOutputToContain('Modules cached successfully.')->assertSuccessful(); + + $cache = require $this->path('bootstrap/cache/modules.php'); + + expect(array_keys($cache))->toBe(['blog', 'shop-order']) + ->and($cache['blog']['path'])->toBe('modules/blog') + ->and(file_get_contents($this->path('bootstrap/cache/modules.php')))->not->toContain($this->basePath); +}); + +it('clears the cache', function () { + $this->artisan('module:cache'); + + $this->artisan('module:clear')->expectsOutputToContain('Module cache cleared successfully.')->assertSuccessful(); + $this->artisan('module:clear')->assertSuccessful(); + + expect(file_exists($this->path('bootstrap/cache/modules.php')))->toBeFalse(); +}); + +it('boots from the cache without scanning the modules', function () { + $this->artisan('module:cache'); + $this->files(['modules/wiki/composer.json' => '{"name": "app/wiki", "autoload": {"psr-4": {"Modules\\\\Wiki\\\\": "src/"}}}']); + $this->reboot(); + + expect(array_keys(Modules::all()))->toBe(['blog', 'shop-order']) + ->and(config('blog.title'))->toBe('Blog') + ->and(Route::has('blog.posts'))->toBeTrue(); +}); + +it('works after the application moved', function () { + $this->artisan('module:cache'); + $moved = $this->temporaryDirectory($this->basePath); + TemporaryDirectory::delete($this->basePath); + $this->basePath = $moved; + $this->reboot(); + + expect(app(ModuleRepository::class)->isCached())->toBeTrue() + ->and(Modules::get('blog')->path)->toBe($moved.'/modules/blog') + ->and(config('blog.title'))->toBe('Blog'); + + $this->get('/blog')->assertOk()->assertSee('Welcome to the blog'); +}); + +it('keeps absolute paths outside of the application', function () { + $modules = $this->temporaryDirectory($this->path('modules')); + $this->files(['config/modules.php' => ' "'.$modules.'"];']); + $this->reboot(); + + $this->artisan('module:cache'); + + expect((require $this->path('bootstrap/cache/modules.php'))['blog']['path'])->toBe($modules.'/blog'); + + $this->reboot(); + + expect(Modules::get('blog')->path)->toBe($modules.'/blog'); +}); diff --git a/tests/Feature/Commands/ComposerRunnerTest.php b/tests/Feature/Commands/ComposerRunnerTest.php new file mode 100644 index 0000000..7a30715 --- /dev/null +++ b/tests/Feature/Commands/ComposerRunnerTest.php @@ -0,0 +1,42 @@ +files(['composer.phar' => '']); + Process::fake(['*' => Process::result()]); + + expect(app(ComposerRunner::class)->remove(['app/blog', 'app/shop-order']))->toBeTrue(); + + Process::assertRan(fn (PendingProcess $process): bool => $process->command === [php_binary(), 'composer.phar', 'remove', '--', 'app/blog', 'app/shop-order']); +}); + +it('tells whether Composer succeeded', function (int $exitCode, bool $successful) { + $this->expectComposer(['update', '--', 'app/blog'], exitCode: $exitCode); + + expect(app(ComposerRunner::class)->update(['app/blog']))->toBe($successful); +})->with([ + 'success' => [0, true], + 'failure' => [1, false], +]); + +it('refuses package names that are not package names', function (string $package) { + $runner = app(ComposerRunner::class); + + expect(fn () => $runner->update([$package]))->toThrow(InvalidName::class) + ->and(fn () => $runner->commandLine('update', [$package]))->toThrow(InvalidName::class); + + Process::assertNothingRan(); +})->with(['--no-scripts', 'app/blog --dev', 'app/blog;rm', 'App/Blog', 'blog']); + +it('prints the command to run by hand', function () { + expect(app(ComposerRunner::class)->commandLine('update', ['app/blog', 'app/shop-order'])) + ->toBe('composer update app/blog app/shop-order'); +}); diff --git a/tests/Feature/Commands/DeleteCommandTest.php b/tests/Feature/Commands/DeleteCommandTest.php new file mode 100644 index 0000000..eda677a --- /dev/null +++ b/tests/Feature/Commands/DeleteCommandTest.php @@ -0,0 +1,147 @@ +installModules(); + $this->composerJson = file_get_contents($this->path('composer.json')); +}); + +it('uninstalls the module with Composer, then deletes it', function () { + $this->expectComposer(['remove', '--', 'app/blog'], during: function () { + // Composer removes the "require" entry itself, while the module still exists. + expect(is_dir($this->path('modules/blog')))->toBeTrue() + ->and($this->readJson('composer.json')['require'])->toHaveKey('app/blog') + ->and($this->readJson('composer.json'))->not->toHaveKey('autoload-dev.psr-4.Modules\\Blog\\Tests\\'); + }); + + $this->artisan('module:delete blog') + ->expectsConfirmation('Delete the [blog] module and all of its files?', 'yes') + ->expectsOutputToContain('Module [blog] deleted.') + ->assertSuccessful(); + + expect(file_exists($this->path('modules/blog')))->toBeFalse() + ->and(array_keys(Modules::all()))->toBe(['shop-order']) + ->and($this->readJson('composer.json')['autoload-dev']['psr-4'])->toBe([]); +}); + +it('deletes without confirmation with --force', function () { + $this->expectComposer(['remove', '--', 'app/shop-order']); + + $this->artisan('module:delete shop-order --force')->assertSuccessful(); + + expect(file_exists($this->path('modules/shop-order')))->toBeFalse(); +}); + +it('keeps the module when the deletion is not confirmed', function () { + $this->artisan('module:delete blog') + ->expectsConfirmation('Delete the [blog] module and all of its files?', 'no') + ->expectsOutputToContain('The module was not deleted.') + ->assertFailed(); + + Process::assertNothingRan(); + + expect(is_dir($this->path('modules/blog')))->toBeTrue() + ->and(file_get_contents($this->path('composer.json')))->toBe($this->composerJson); +}); + +it('requires --force in non-interactive mode', function () { + $this->artisan('module:delete blog --no-interaction') + ->expectsOutputToContain('Use --force to delete a module in non-interactive mode.') + ->assertFailed(); + + Process::assertNothingRan(); + + expect(is_dir($this->path('modules/blog')))->toBeTrue(); +}); + +it('restores composer.json and keeps the module when Composer fails', function () { + $this->expectComposer(['remove', '--', 'app/blog'], exitCode: 1); + + $this->artisan('module:delete blog --force') + ->expectsOutputToContain('Composer failed to remove the module, nothing was deleted.') + ->assertFailed(); + + expect(is_dir($this->path('modules/blog')))->toBeTrue() + ->and(file_get_contents($this->path('composer.json')))->toBe($this->composerJson) + ->and(array_keys(Modules::all()))->toBe(['blog', 'shop-order']); +}); + +it('only edits composer.json with --no-update', function () { + $this->artisan('module:delete blog --force --no-update') + ->expectsOutputToContain('Uninstall it with: composer update app/blog') + ->assertSuccessful(); + + Process::assertNothingRan(); + + expect(file_exists($this->path('modules/blog')))->toBeFalse() + ->and(is_link($this->path('vendor/app/blog')))->toBeFalse() + ->and(is_link($this->path('vendor/app/shop-order')))->toBeTrue() + ->and($this->readJson('composer.json')['require'])->toBe([ + 'php' => '^8.3', + 'app/shop-order' => '*@dev', + 'laravel/framework' => '^13.0', + ]); +}); + +it('updates the module cache when it exists', function () { + $this->artisan('module:cache'); + $this->expectComposer(['remove', '--', 'app/blog']); + + $this->artisan('module:delete blog --force')->assertSuccessful(); + + expect(array_keys(require $this->path('bootstrap/cache/modules.php')))->toBe(['shop-order']); +}); + +it('refuses to delete a missing module', function (string $name) { + $this->artisan('module:delete', ['name' => $name, '--force' => true]) + ->expectsOutputToContain("Module [{$name}] not found.") + ->assertFailed(); + + Process::assertNothingRan(); + + expect(file_get_contents($this->path('composer.json')))->toBe($this->composerJson); +})->with(['notes', 'wiki', '../modules/blog']); + +it('refuses to delete a module linked from elsewhere', function () { + $target = $this->temporaryDirectory(); + file_put_contents($target.'/composer.json', '{"name": "app/linked", "autoload": {"psr-4": {"Modules\\\\Linked\\\\": "src/"}}}'); + symlink($target, $this->path('modules/linked')); + $this->reboot(); + + $this->artisan('module:delete linked --force') + ->expectsOutputToContain('[modules/linked] is a symbolic link. Remove the module by hand.') + ->assertFailed(); + + Process::assertNothingRan(); + + expect(file_exists($target.'/composer.json'))->toBeTrue(); +})->skipOnWindows(); + +it('fails on an invalid composer.json of the application', function () { + $this->files(['composer.json' => '[']); + + $this->artisan('module:delete blog --force') + ->expectsOutputToContain('composer.json] is not valid JSON') + ->assertFailed(); + + Process::assertNothingRan(); + + expect(is_dir($this->path('modules/blog')))->toBeTrue(); +}); + +it('reports a module directory that can not be deleted', function () { + $this->expectComposer(['remove', '--', 'app/blog']); + chmod($this->path('modules/blog/config'), 0555); + + try { + $this->artisan('module:delete blog --force') + ->expectsOutputToContain('[modules/blog] could not be deleted completely. Delete it by hand.') + ->assertFailed(); + } finally { + chmod($this->path('modules/blog/config'), 0755); + } +})->skip(fn () => DIRECTORY_SEPARATOR === '\\' || posix_getuid() === 0, 'File permissions are not enforced.'); diff --git a/tests/Feature/Commands/DoctorCommandTest.php b/tests/Feature/Commands/DoctorCommandTest.php new file mode 100644 index 0000000..debb3b8 --- /dev/null +++ b/tests/Feature/Commands/DoctorCommandTest.php @@ -0,0 +1,145 @@ +installModules(); + + [$status, $output] = doctor(); + + expect($status)->toBe(0) + ->and($output)->toContain('Configuration', 'blog', 'shop-order', 'No problems found.') + ->not->toContain('FAIL', 'WARN'); +}); + +it('reports modules that are not installed', function () { + [$status, $output] = doctor(); + + expect($status)->toBe(1) + ->and($output)->toContain( + '✗ The application does not require [app/blog]: run "php artisan module:sync".', + '✗ [app/blog] can be installed from Packagist: run "php artisan module:sync" to exclude the vendor.', + '✗ [app/blog] is not installed: run "php artisan module:sync".', + '! The application does not autoload [Modules\\Blog\\Tests\\] for development: run "php artisan module:sync".', + '✗ [app/shop-order] is not installed', + 'Found 6 error(s) and 1 warning(s).', + ); +}); + +it('reports a package installed from elsewhere', function () { + $this->installModules(); + unlink($this->path('vendor/app/blog')); + mkdir($this->path('vendor/app/blog')); + + [$status, $output] = doctor(); + + expect($status)->toBe(1) + ->and($output)->toContain('✗ [vendor/app/blog] is not a link to [modules/blog].'); +}); + +it('reports outdated installed metadata', function () { + $this->installModules(); + $this->files(['modules/blog/composer.json' => json_encode([...$this->readJson('modules/blog/composer.json'), 'require' => ['php' => '^8.4']])]); + + [$status, $output] = doctor(); + + expect($status)->toBe(0) + ->and($output)->toMatch('/^ blog \\.+ WARN\n ! composer\\.json changed since the module was installed: run "php artisan module:sync"\\.$/m') + ->toMatch('/^ shop-order \\.+ OK$/m') + ->toContain('Found 1 warning(s).'); +}); + +it('reports problems of the module structure', function () { + $this->installModules(); + $composerJson = $this->readJson('modules/blog/composer.json'); + $composerJson['autoload']['psr-4'] = ['Modules\\Blog\\' => 'app/', 'Modules\\Blog\\Database\\Factories\\' => 'database/Factories/']; + $composerJson['extra'] = ['laravel' => ['providers' => ['Modules\\Blog\\Providers\\MissingServiceProvider', 42]]]; + $this->files([ + 'modules/blog/composer.json' => json_encode($composerJson), + 'modules/blog/routes/zeta/users.php' => ' ' ' 'reboot(); + + [$status, $output] = doctor(); + + expect($status)->toBe(1) + ->and($output)->toContain( + '✗ The directory of the [Modules\\Blog] namespace does not exist.', + '✗ "autoload.psr-4" must map [Modules\\Blog\\Database\\Factories\\] to [database/factories/].', + '✗ "autoload.psr-4" must map [Modules\\Blog\\Database\\Seeders\\] to [database/seeders/].', + '✗ The provider [Modules\\Blog\\Providers\\MissingServiceProvider] does not exist.', + '✗ The provider [int] does not exist.', + " ! The route file [routes/admin/users.php] is not in a route group of config/modules.php.\n ! The route file [routes/zeta/users.php] is not in a route group of config/modules.php.", + ) + ->toMatch('/^ blog \\.+ FAIL$/m') + ->not->toContain('secret.php', 'fixture.php', 'routes/api/posts.php'); +}); + +it('accepts equivalent autoload paths and skips directories a module does not have', function () { + // shop-order has no database/factories directory. + $composerJson = $this->readJson('modules/shop-order/composer.json'); + $composerJson['autoload']['psr-4'] = ['Modules\\ShopOrder\\' => './src', 'Modules\\ShopOrder\\Database\\Seeders\\' => './database/seeders']; + $composerJson['extra'] = ['laravel' => ['providers' => ['Modules\\ShopOrder\\Providers\\MissingServiceProvider']]]; + $this->files(['modules/shop-order/composer.json' => json_encode($composerJson)]); + $this->installModules(); + + [$status, $output] = doctor(); + + expect($status)->toBe(1) + ->and($output)->toMatch('/^ shop-order \\.+ FAIL\n ✗ The provider \\[Modules\\\\ShopOrder\\\\Providers\\\\MissingServiceProvider\\] does not exist\\.\n\n/m') + ->toContain('Found 1 error(s) and 0 warning(s).'); +}); + +it('reports problems of the configuration', function () { + $this->installModules(); + $this->files([ + 'config/modules.php' => ' []];', + 'config/octane.php' => ' ["app"]];', + ]); + $this->reboot(); + $this->artisan('module:cache'); + $this->files(['modules/wiki/composer.json' => '{"name": "app/wiki", "autoload": {"psr-4": {"Modules\\\\Wiki\\\\": "src/"}}}']); + + [$status, $output] = doctor(); + + expect($status)->toBe(0) + ->and($output)->toContain( + '! Unknown keys in config/modules.php: paths.', + '! The module cache is outdated: run "php artisan module:cache".', + ) + ->not->toContain('octane:start'); +}); + +it('warns when Octane can not watch the modules', function () { + $modules = $this->temporaryDirectory($this->path('modules')); + $this->files([ + 'config/modules.php' => ' "'.$modules.'"];', + 'config/octane.php' => ' ["app"]];', + ]); + $this->reboot(); + + [, $output] = doctor(); + + expect($output)->toContain('! The modules path is outside of the application, so "octane:start --watch" does not watch it.'); +}); + +it('fails on an invalid composer.json of the application', function () { + $this->files(['composer.json' => '[']); + + [$status, $output] = doctor(); + + expect($status)->toBe(1) + ->and($output)->toContain('composer.json] is not valid JSON'); +}); diff --git a/tests/Feature/Commands/ListCommandTest.php b/tests/Feature/Commands/ListCommandTest.php new file mode 100644 index 0000000..9a1a1e1 --- /dev/null +++ b/tests/Feature/Commands/ListCommandTest.php @@ -0,0 +1,47 @@ +toBe(<<<'TXT' + +------------+----------------+-------------------+--------------------+ + | Name | Package | Namespace | Path | + +------------+----------------+-------------------+--------------------+ + | blog | app/blog | Modules\Blog | modules/blog | + | shop-order | app/shop-order | Modules\ShopOrder | modules/shop-order | + +------------+----------------+-------------------+--------------------+ + + TXT); +}); + +it('lists what is loaded from the modules with -v', function () { + $this->files(['modules/wiki/composer.json' => '{"name": "app/wiki", "autoload": {"psr-4": {"Modules\\\\Wiki\\\\": "src/"}}}']); + $this->reboot(); + + expect(listModules(['-v' => true]))->toBe(<<<'TXT' + +------------+----------------+-------------------+--------------------+-----------------------------------------------------------------------------------------------------------+ + | Name | Package | Namespace | Path | Loads | + +------------+----------------+-------------------+--------------------+-----------------------------------------------------------------------------------------------------------+ + | blog | app/blog | Modules\Blog | modules/blog | config (blog), lang (+json), views, migrations, api routes (3), web routes (1), seeders (4), commands (2) | + | shop-order | app/shop-order | Modules\ShopOrder | modules/shop-order | config (shop-order), lang, web routes (1), seeders (2) | + | wiki | app/wiki | Modules\Wiki | modules/wiki | - | + +------------+----------------+-------------------+--------------------+-----------------------------------------------------------------------------------------------------------+ + + TXT); +}); + +it('warns when there are no modules', function () { + $this->files(['config/modules.php' => ' base_path("empty")];']); + $this->reboot(); + + expect(listModules())->toBe("\n WARN No modules found in [empty].\n\n"); +}); diff --git a/tests/Feature/Commands/MakeCommandTest.php b/tests/Feature/Commands/MakeCommandTest.php new file mode 100644 index 0000000..f156a7f --- /dev/null +++ b/tests/Feature/Commands/MakeCommandTest.php @@ -0,0 +1,212 @@ +composerJson = file_get_contents($this->path('composer.json')); +}); + +it('creates a module and installs it with Composer', function () { + $this->expectComposer(['update', '--', 'app/wiki-pages'], during: function () { + // Composer reads the module and the updated composer.json. + expect($this->readJson('composer.json')['require'])->toHaveKey('app/wiki-pages') + ->and(file_exists($this->path('modules/wiki-pages/composer.json')))->toBeTrue(); + }); + + $this->artisan('module:make', ['name' => 'WikiPages']) + ->expectsOutputToContain('Module [wiki-pages] created in [modules/wiki-pages].') + ->assertSuccessful(); + + Process::assertRanTimes(fn () => true, 1); + + expect($this->readJson('modules/wiki-pages/composer.json'))->toBe([ + 'name' => 'app/wiki-pages', + 'description' => 'The wiki-pages module.', + 'type' => 'library', + 'autoload' => ['psr-4' => [ + 'Modules\\WikiPages\\' => 'src/', + 'Modules\\WikiPages\\Database\\Factories\\' => 'database/factories/', + 'Modules\\WikiPages\\Database\\Seeders\\' => 'database/seeders/', + ]], + 'autoload-dev' => ['psr-4' => ['Modules\\WikiPages\\Tests\\' => 'tests/']], + ]) + ->and(collect(File::allFiles($this->path('modules/wiki-pages'), true)) + ->map(fn (SplFileInfo $file): string => $file->getRelativePathname())->sort()->values()->all()) + ->toBe([ + 'composer.json', + 'database/factories/.gitkeep', + 'database/migrations/.gitkeep', + 'database/seeders/.gitkeep', + 'src/Models/.gitkeep', + 'tests/.gitkeep', + ]) + ->and($this->readJson('composer.json'))->toBe([ + 'name' => 'laraneat/fixture-app', + 'type' => 'project', + 'require' => [ + 'php' => '^8.3', + 'app/wiki-pages' => '*@dev', + 'laravel/framework' => '^13.0', + ], + 'autoload' => ['psr-4' => ['App\\' => 'app/']], + 'config' => ['sort-packages' => true], + 'minimum-stability' => 'stable', + 'repositories' => [ + ['type' => 'path', 'url' => 'modules/*', 'options' => ['symlink' => true]], + ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['app/*']], + ['packagist.org' => false], + ], + 'autoload-dev' => ['psr-4' => ['Modules\\WikiPages\\Tests\\' => 'modules/wiki-pages/tests/']], + ]) + ->and(Modules::get('wiki-pages')->namespace)->toBe('Modules\\WikiPages'); +}); + +it('only edits composer.json with --no-update', function () { + $this->artisan('module:make wiki --no-update') + ->expectsOutputToContain('Install it with: composer update app/wiki') + ->assertSuccessful(); + + Process::assertNothingRan(); + + expect($this->readJson('composer.json')['require'])->toHaveKey('app/wiki'); +}); + +it('reports a Composer failure and keeps the module', function () { + $this->expectComposer(['update', '--', 'app/wiki'], exitCode: 2); + + $this->artisan('module:make wiki') + ->expectsOutputToContain('Composer failed to install the module. Fix the problem and run: composer update app/wiki') + ->assertFailed(); + + expect(is_dir($this->path('modules/wiki')))->toBeTrue() + ->and($this->readJson('composer.json')['require'])->toHaveKey('app/wiki'); +}); + +it('uses the vendor and the namespace of the config', function () { + $this->files(['config/modules.php' => ' "acme", "namespace" => "Acme\\\\Domain"];']); + $this->reboot(); + + $this->artisan('module:make billing --no-update')->assertSuccessful(); + + expect($this->readJson('modules/billing/composer.json')) + ->name->toBe('acme/billing') + ->autoload->toBe(['psr-4' => [ + 'Acme\\Domain\\Billing\\' => 'src/', + 'Acme\\Domain\\Billing\\Database\\Factories\\' => 'database/factories/', + 'Acme\\Domain\\Billing\\Database\\Seeders\\' => 'database/seeders/', + ]]) + ->and($this->readJson('composer.json')['repositories'][1]['exclude'])->toBe(['acme/*']); +}); + +it('updates the module cache when it exists', function () { + $this->artisan('module:cache'); + + $this->artisan('module:make wiki --no-update')->assertSuccessful(); + + expect(array_keys(require $this->path('bootstrap/cache/modules.php')))->toBe(['blog', 'shop-order', 'wiki']); +}); + +it('renders a preset of the application', function () { + Date::setTestNow('2026-09-23 10:20:30'); + $this->files([ + 'stubs/module/api/composer.json.stub' => <<<'JSON' + { + "name": "{{ package }}", + "autoload": {"psr-4": {"{{ namespace|json }}\\": "src/"}}, + "extra": {"module": "{{ name|studly }}", "vendor": "{{ vendor }}"} + } + JSON, + 'stubs/module/api/src/Models/{{ name|studly|singular }}.php.stub' => " ' '# {{ name }} stays as is', + ]); + + $this->artisan('module:make shop-items --preset=api --no-update')->assertSuccessful(); + + expect(file_get_contents($this->path('modules/shop-items/src/Models/ShopItem.php'))) + ->toBe("and(file_get_contents($this->path('modules/shop-items/database/migrations/2026_09_23_102030_create_shop_items_table.php'))) + ->toBe('and(file_get_contents($this->path('modules/shop-items/README.md')))->toBe('# {{ name }} stays as is') + ->and($this->readJson('modules/shop-items/composer.json')['extra'])->toBe(['module' => 'ShopItems', 'vendor' => 'app']); +}); + +it('prefers the published default template', function () { + $this->files([ + 'stubs/module/default/composer.json.stub' => '{"name": "{{ package }}", "autoload": {"psr-4": {"{{ namespace|json }}\\\\": "lib/"}}}', + ]); + + $this->artisan('module:make wiki --no-update')->assertSuccessful(); + + expect(Modules::get('wiki')->sourcePath)->toBe($this->path('modules/wiki/lib')); +}); + +it('refuses invalid input without touching anything', function (string $command, string $error) { + $this->files(['stubs/module/broken/src/.gitkeep' => '']); + + $this->artisan($command)->expectsOutputToContain($error)->assertFailed(); + + Process::assertNothingRan(); + + expect(file_get_contents($this->path('composer.json')))->toBe($this->composerJson) + ->and(array_keys(app(ModuleRepository::class)->manifest()))->toBe(['blog', 'shop-order']); +})->with([ + 'invalid name' => ['module:make 1blog', 'Invalid module name [1blog]'], + 'existing module' => ['module:make Blog', 'The directory [modules/blog] already exists.'], + 'existing directory' => ['module:make notes', 'The directory [modules/notes] already exists.'], + 'unsafe preset' => ['module:make wiki --preset=../api', 'the preset name may contain'], + 'missing preset' => ['module:make wiki --preset=api', 'the preset does not exist.'], + 'preset without composer.json' => ['module:make wiki --preset=broken', 'the template must contain a "composer.json"'], +]); + +it('removes the module when its composer.json is invalid', function (string $composerJson, string $error) { + $this->files(['stubs/module/default/composer.json.stub' => $composerJson]); + + $this->artisan('module:make wiki')->expectsOutputToContain($error)->assertFailed(); + + Process::assertNothingRan(); + + expect(file_exists($this->path('modules/wiki')))->toBeFalse() + ->and(file_get_contents($this->path('composer.json')))->toBe($this->composerJson) + ->and(array_keys(app(ModuleRepository::class)->manifest()))->toBe(['blog', 'shop-order']); +})->with([ + 'another package name' => ['{"name": "app/other", "autoload": {"psr-4": {"Modules\\\\Wiki\\\\": "src/"}}}', 'the package name must be [app/wiki], [app/other] given.'], + 'no namespace' => ['{"name": "{{ package }}"}', 'the first "autoload.psr-4" entry of composer.json must map'], + 'invalid JSON' => ['{"name": ', 'composer.json is not valid JSON'], +]); + +it('refuses an invalid vendor', function () { + $this->files(['config/modules.php' => ' "Acme Inc"];']); + $this->reboot(); + + $this->artisan('module:make wiki')->expectsOutputToContain('Invalid vendor [Acme Inc]')->assertFailed(); + + expect(file_exists($this->path('modules/wiki')))->toBeFalse(); +}); + +it('removes the module when writing it fails', function () { + chmod($this->path('modules'), 0555); + + try { + expect(fn () => $this->artisan('module:make wiki')->run())->toThrow(ErrorException::class); + } finally { + chmod($this->path('modules'), 0755); + } + + Process::assertNothingRan(); + + expect(file_exists($this->path('modules/wiki')))->toBeFalse() + ->and(file_get_contents($this->path('composer.json')))->toBe($this->composerJson); +})->skip(fn () => DIRECTORY_SEPARATOR === '\\' || posix_getuid() === 0, 'File permissions are not enforced.'); + +it('uses the default template for --preset without a value', function () { + $this->artisan('module:make wiki --preset --no-update')->assertSuccessful(); + + expect(file_exists($this->path('modules/wiki/src/Models/.gitkeep')))->toBeTrue(); +}); diff --git a/tests/Feature/Commands/SyncCommandTest.php b/tests/Feature/Commands/SyncCommandTest.php new file mode 100644 index 0000000..a4b0557 --- /dev/null +++ b/tests/Feature/Commands/SyncCommandTest.php @@ -0,0 +1,141 @@ +expectComposer(['update', '--', 'app/blog', 'app/shop-order'], during: function () { + expect($this->readJson('composer.json')['require'])->toHaveKeys(['app/blog', 'app/shop-order']); + }); + + $this->artisan('module:sync') + ->expectsOutputToContain('Modules are in sync with Composer.') + ->assertSuccessful(); + + expect($this->readJson('composer.json')) + ->require->toBe([ + 'php' => '^8.3', + 'app/blog' => '*@dev', + 'app/shop-order' => '*@dev', + 'laravel/framework' => '^13.0', + ]) + ->repositories->toBe([ + ['type' => 'path', 'url' => 'modules/*', 'options' => ['symlink' => true]], + ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['app/*']], + ['packagist.org' => false], + ]) + ->{'autoload-dev'}->toBe(['psr-4' => ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']]); +}); + +it('does nothing when the modules are in sync', function () { + $this->installModules(); + $composerJson = file_get_contents($this->path('composer.json')); + + $this->artisan('module:sync') + ->expectsOutputToContain('Modules are in sync with Composer.') + ->assertSuccessful(); + + Process::assertNothingRan(); + + expect(file_get_contents($this->path('composer.json')))->toBe($composerJson); +}); + +it('installs the modules whose composer.json changed', function (array $change) { + $this->installModules(); + $composerJson = $this->readJson('modules/shop-order/composer.json'); + $this->files(['modules/shop-order/composer.json' => json_encode(array_replace_recursive($composerJson, $change))]); + + $this->expectComposer(['update', '--', 'app/shop-order']); + + $this->artisan('module:sync')->assertSuccessful(); +})->with([ + 'require' => [['require' => ['php' => '^8.4']]], + 'autoload' => [['autoload' => ['files' => ['helpers.php']]]], + 'extra' => [['extra' => ['laravel' => ['providers' => ['Modules\\ShopOrder\\Providers\\ShopOrderServiceProvider']]]]], +]); + +it('ignores the key order of the installed metadata', function () { + $this->installModules(); + $installed = $this->readJson('vendor/composer/installed.json'); + $installed['packages'][0]['autoload']['psr-4'] = array_reverse($installed['packages'][0]['autoload']['psr-4'], true); + $this->files(['vendor/composer/installed.json' => json_encode($installed)]); + + $this->artisan('module:sync')->assertSuccessful(); + + Process::assertNothingRan(); +}); + +it('installs a module missing from installed.json', function () { + $this->installModules(['blog']); + $this->expectComposer(['update', '--', 'app/shop-order']); + + $this->artisan('module:sync')->assertSuccessful(); +}); + +it('installs a module that is not required yet', function () { + $this->installModules(); + $composerJson = $this->readJson('composer.json'); + unset($composerJson['require']['app/blog']); + $this->files(['composer.json' => json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)]); + + $this->expectComposer(['update', '--', 'app/blog']); + + $this->artisan('module:sync')->assertSuccessful(); + + expect($this->readJson('composer.json')['require'])->toHaveKey('app/blog'); +}); + +it('picks up a module created by hand', function () { + $this->installModules(); + $this->artisan('module:cache'); + $this->files(['modules/wiki/composer.json' => '{"name": "app/wiki", "autoload": {"psr-4": {"Modules\\\\Wiki\\\\": "src/"}}}']); + + $this->expectComposer(['update', '--', 'app/wiki']); + + $this->artisan('module:sync')->assertSuccessful(); + + expect(array_keys(require $this->path('bootstrap/cache/modules.php')))->toBe(['blog', 'shop-order', 'wiki']); +}); + +it('only edits composer.json with --no-update', function () { + $this->artisan('module:sync --no-update') + ->expectsOutputToContain('Install the outdated modules with: composer update app/blog app/shop-order') + ->assertSuccessful(); + + Process::assertNothingRan(); + + expect($this->readJson('composer.json')['require'])->toHaveKeys(['app/blog', 'app/shop-order']); +}); + +it('fails when Composer fails', function () { + $this->expectComposer(['update', '--', 'app/blog', 'app/shop-order'], exitCode: 1); + + $this->artisan('module:sync') + ->expectsOutputToContain('Composer failed to install the modules. Fix the problem and run: composer update app/blog app/shop-order') + ->assertFailed(); +}); + +it('fails on an invalid composer.json of the application', function () { + $this->files(['composer.json' => '{"repositories": "packagist"}']); + + $this->artisan('module:sync') + ->expectsOutputToContain('"repositories" must be an array or an object.') + ->assertFailed(); + + Process::assertNothingRan(); + + expect(file_get_contents($this->path('composer.json')))->toBe('{"repositories": "packagist"}'); +}); + +it('uses the vendor directory of the Composer config', function () { + $this->installModules(); + rename($this->path('vendor'), $this->path('libraries')); + $composerJson = $this->readJson('composer.json'); + $composerJson['config']['vendor-dir'] = 'libraries'; + $this->files(['composer.json' => json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n"]); + + $this->artisan('module:sync')->assertSuccessful(); + + Process::assertNothingRan(); +}); diff --git a/tests/Feature/Conventions/ConfigTest.php b/tests/Feature/Conventions/ConfigTest.php new file mode 100644 index 0000000..cecb399 --- /dev/null +++ b/tests/Feature/Conventions/ConfigTest.php @@ -0,0 +1,51 @@ +toBe(['title' => 'Blog', 'per_page' => 15]) + ->and(config('shop-order.currency'))->toBe('EUR'); +}); + +it('lets the application config override the module config', function () { + $this->files(['config/blog.php' => ' 50, "author" => "admin"];']); + $this->reboot(); + + expect(config('blog'))->toBe(['title' => 'Blog', 'per_page' => 50, 'author' => 'admin']); +}); + +it('merges the config before the providers boot', function () { + $this->app->register(new class($this->app) extends ServiceProvider + { + public ?string $title = null; + + public function boot(): void + { + $this->title = config('blog.title'); + } + }); + + expect(collect($this->app->getProviders(ServiceProvider::class))->last()->title)->toBe('Blog'); +}); + +it('does not read the module config when the config is cached', function () { + // Testbench always loads the config files, so the registrar is run by hand. + $this->app->instance('config_loaded_from_cache', true); + config(['blog' => null]); + + (new ResourceRegistrar($this->app, app(ModuleRepository::class)->manifest()))->registerConfig(); + + expect($this->app->configurationIsCached())->toBeTrue() + ->and(config('blog'))->toBeNull(); +}); + +it('refuses a config file that does not return an array', function () { + $this->files(['modules/blog/config/broken.php' => ' $this->reboot())->toThrow(InvalidModule::class, 'config/broken.php must return an array.'); +}); diff --git a/tests/Feature/Conventions/MigrationsTest.php b/tests/Feature/Conventions/MigrationsTest.php new file mode 100644 index 0000000..e67d156 --- /dev/null +++ b/tests/Feature/Conventions/MigrationsTest.php @@ -0,0 +1,21 @@ +paths())->toBe([$this->path('modules/blog/database/migrations')]); +}); + +it('runs the migrations of the modules', function () { + config(['database.default' => 'testing']); + + $this->artisan('migrate')->assertSuccessful(); + + expect(Schema::hasTable('posts'))->toBeTrue(); + + $this->artisan('migrate:rollback')->assertSuccessful(); + + expect(Schema::hasTable('posts'))->toBeFalse(); +}); diff --git a/tests/Feature/Conventions/RoutesTest.php b/tests/Feature/Conventions/RoutesTest.php new file mode 100644 index 0000000..e325dcb --- /dev/null +++ b/tests/Feature/Conventions/RoutesTest.php @@ -0,0 +1,80 @@ +getRoutes()) + ->reject(static fn (RouteObject $route): bool => str_starts_with($route->uri(), 'storage/')) + ->mapWithKeys(static fn (RouteObject $route): array => [$route->uri() => $route->gatherMiddleware()]) + ->all(); +} + +it('loads the route files into the route groups', function () { + expect(moduleRoutes())->toBe([ + 'api/posts' => ['api'], + 'api/v1/feed' => ['api'], + 'api/v1/admin/stats' => ['api'], + 'blog' => ['web'], + 'orders' => ['web'], + ]); + + $this->get('/api/v1/admin/stats')->assertOk()->assertSee('blog stats'); + $this->get('/blog')->assertOk()->assertSee('Blog: Welcome to the blog'); + $this->get('/orders')->assertOk(); + expect(route('blog.posts', absolute: false))->toBe('/api/posts'); +}); + +it('applies every attribute of a route group', function () { + $this->files(['config/modules.php' => <<<'PHP' + [ + 'api' => ['path' => 'routes/api', 'prefix' => '/api/', 'as' => 'api.', 'middleware' => ['api', 'auth:sanctum']], + 'web' => ['path' => 'routes/web', 'domain' => 'shop.test'], + ]]; + PHP]); + $this->reboot(); + + // Routes with a domain are listed first. + expect(moduleRoutes())->toBe([ + 'blog' => [], + 'orders' => [], + 'api/posts' => ['api', 'auth:sanctum'], + 'api/v1/feed' => ['api', 'auth:sanctum'], + 'api/v1/admin/stats' => ['api', 'auth:sanctum'], + ]) + ->and(Route::getRoutes()->getByName('api.blog.posts')?->uri())->toBe('api/posts') + ->and(Route::getRoutes()->getByName('shop-order.orders')?->getDomain())->toBe('shop.test'); +}); + +it('uses the directory as the prefix of a group without a prefix', function () { + $this->files(['config/modules.php' => ' ["api" => ["path" => "routes/api"]]];']); + $this->reboot(); + + expect(array_keys(moduleRoutes()))->toBe(['posts', 'v1/feed', 'v1/admin/stats']); +}); + +it('does not load the route files when the routes are cached', function () { + // Laravel loads the cached routes after the providers boot, Testbench does not load them at all. + $this->files(['bootstrap/cache/routes-v7.php' => 'reboot(); + + expect(app()->routesAreCached())->toBeTrue() + ->and(moduleRoutes())->toBe([]); +}); + +it('skips route groups that are not arrays', function () { + // Route files register their routes through the facade. + app()->instance('router', $router = new Router(app('events'), app())); + Route::clearResolvedInstance('router'); + $groups = ['api' => 'routes/api', 'web' => ['path' => 'routes/web']]; + + (new RouteRegistrar($router, $groups, app(ModuleRepository::class)->manifest()))->register(); + + expect(array_map(static fn (RouteObject $route): string => $route->uri(), $router->getRoutes()->getRoutes()))->toBe(['blog', 'orders']); +}); diff --git a/tests/Feature/Conventions/TranslationsTest.php b/tests/Feature/Conventions/TranslationsTest.php new file mode 100644 index 0000000..319849a --- /dev/null +++ b/tests/Feature/Conventions/TranslationsTest.php @@ -0,0 +1,49 @@ +toBe('Welcome to the blog') + ->and(__('shop-order::orders.created'))->toBe('Order created'); +}); + +it('registers the JSON translations of the modules that have them', function () { + expect(__('Read more'))->toBe('Read the whole post') + ->and(app('translation.loader')->jsonPaths())->toBe([$this->path('modules/blog/lang')]) + ->and(app('translation.loader')->namespaces())->toMatchArray([ + 'blog' => $this->path('modules/blog/lang'), + 'shop-order' => $this->path('modules/shop-order/lang'), + ]); +}); + +it('lets the application override the module translations', function () { + $this->files(['lang/vendor/blog/en/messages.php' => ' "Hello"];']); + + expect(__('blog::messages.welcome'))->toBe('Hello'); +}); + +it('adds the translations whether the translator is resolved before or after the modules boot', function (bool $before) { + $manifest = app(ModuleRepository::class)->manifest(); + $app = new Application($this->path()); + Container::setInstance($this->app); + $app->instance('files', new Filesystem); + $app->instance('config', new Repository(['app' => ['locale' => 'en', 'fallback_locale' => 'en']])); + (new TranslationServiceProvider($app))->register(); + + $translator = $before ? $app->make('translator') : null; + + (new ResourceRegistrar($app, $manifest))->boot(); + + $translator ??= $app->make('translator'); + + expect($translator->get('blog::messages.welcome'))->toBe('Welcome to the blog') + ->and($translator->get('Read more'))->toBe('Read the whole post'); +})->with(['resolved before' => true, 'resolved after' => false]); diff --git a/tests/Feature/Conventions/ViewsTest.php b/tests/Feature/Conventions/ViewsTest.php new file mode 100644 index 0000000..a283ccc --- /dev/null +++ b/tests/Feature/Conventions/ViewsTest.php @@ -0,0 +1,40 @@ + 'News'])->render())->toBe("Blog: News\n") + ->and(app('view')->getFinder()->getHints())->toMatchArray(['blog' => [$this->path('modules/blog/resources/views')]]) + ->and(app('view')->getFinder()->getHints())->not->toHaveKey('shop-order'); +}); + +it('does not look for view overrides in the vendor directory', function () { + $this->files(['resources/views/vendor/blog/index.blade.php' => 'Overridden']); + + expect(view('blog::index', ['title' => 'News'])->render())->toBe("Blog: News\n"); +}); + +it('registers the Blade components of the modules under their names', function () { + $this->files([ + 'modules/blog/src/View/Components/Badge.php' => <<<'PHP' + 'anonymous card', + ]); + + expect(Blade::render(' '))->toBe('class badge anonymous card'); +}); diff --git a/tests/Feature/Discovery/CommandsTest.php b/tests/Feature/Discovery/CommandsTest.php new file mode 100644 index 0000000..1d4b530 --- /dev/null +++ b/tests/Feature/Discovery/CommandsTest.php @@ -0,0 +1,66 @@ +artisan('blog:publish')->expectsOutputToContain('blog:publish done')->assertSuccessful(); + $this->artisan('blog:archive')->expectsOutputToContain('blog:archive done')->assertSuccessful(); + + expect(Artisan::all()) + ->toHaveKey('blog:publish') + ->toHaveKey('blog:archive') + ->and(Artisan::all()['blog:publish'])->toBeInstanceOf(PublishPostsCommand::class) + ->and(Artisan::all()['blog:archive'])->toBeInstanceOf(ArchivePostsCommand::class); +}); + +it('skips abstract classes and files that are not commands', function () { + $commands = collect(Artisan::all())->map(static fn (object $command): string => $command::class)->values(); + + expect($commands->filter(static fn (string $class): bool => str_starts_with($class, 'Modules\\'))->sort()->values()->all()) + ->toBe([ArchivePostsCommand::class, PublishPostsCommand::class]); +}); + +it('registers the commands lazily', function () { + $this->artisan('module:list')->assertSuccessful(); + + $console = (new ReflectionProperty(Kernel::class, 'artisan'))->getValue(app(Illuminate\Contracts\Console\Kernel::class)); + $loader = (new ReflectionProperty(Application::class, 'commandLoader'))->getValue($console); + + expect($loader->has('blog:publish'))->toBeTrue() + ->and((new ReflectionProperty(Application::class, 'commands'))->getValue($console)) + ->not->toHaveKey('blog:publish'); +}); + +it('uses the "make:command" generator namespace', function () { + $this->files([ + 'config/modules.php' => ' ["make:command" => "Commands"]];', + 'modules/blog/src/Commands/SyncPostsCommand.php' => <<<'PHP' + info('synced'); + } + } + PHP, + ]); + $this->reboot(); + + $this->artisan('blog:sync')->expectsOutputToContain('synced')->assertSuccessful(); + + expect(Artisan::all())->not->toHaveKey('blog:publish'); +}); diff --git a/tests/Feature/Discovery/FactoriesTest.php b/tests/Feature/Discovery/FactoriesTest.php new file mode 100644 index 0000000..d3c52a6 --- /dev/null +++ b/tests/Feature/Discovery/FactoriesTest.php @@ -0,0 +1,86 @@ +toBe($factory); +})->with([ + 'model' => ['Modules\\Blog\\Models\\Post', 'Modules\\Blog\\Database\\Factories\\PostFactory'], + 'nested model' => ['Modules\\Blog\\Models\\Admin\\Role', 'Modules\\Blog\\Database\\Factories\\Admin\\RoleFactory'], + 'model outside Models' => ['Modules\\ShopOrder\\Entities\\Order', 'Modules\\ShopOrder\\Database\\Factories\\Entities\\OrderFactory'], + 'application model' => ['App\\Models\\User', 'Database\\Factories\\UserFactory'], + 'application class' => ['App\\User', 'Database\\Factories\\UserFactory'], + 'package model' => ['Vendor\\Package\\Models\\Thing', 'Database\\Factories\\Vendor\\Package\\Models\\ThingFactory'], + 'unknown module namespace' => ['Modules\\Wiki\\Models\\Page', 'Database\\Factories\\Modules\\Wiki\\Models\\PageFactory'], +]); + +it('pairs module factories with the models of the module', function () { + expect(PostFactory::new()->modelName())->toBe(Post::class) + ->and(Post::factory()->make()->title)->toBe('Hello'); +}); + +it('falls back to the root namespace of the module when the model is not in Models', function () { + $this->files(['modules/shop-order/database/factories/InvoiceFactory.php' => <<<'PHP' + modelName())->toBe('Modules\\ShopOrder\\Invoice'); +}); + +it('keeps the default behavior for application factories', function () { + eval('namespace Database\\Factories; class WidgetFactory extends \\Illuminate\\Database\\Eloquent\\Factories\\Factory { public function definition(): array { return []; } }'); + + expect((new WidgetFactory)->modelName())->toBe('App\\Widget'); +}); + +it('picks the most specific module namespace', function () { + $this->files(['modules/blog-comments/composer.json' => json_encode([ + 'name' => 'app/blog-comments', + 'autoload' => ['psr-4' => ['Modules\\Blog\\Comments\\' => 'src/']], + ])]); + $this->reboot(); + + expect(Factory::resolveFactoryName('Modules\\Blog\\Comments\\Models\\Comment')) + ->toBe('Modules\\Blog\\Comments\\Database\\Factories\\CommentFactory') + ->and(Factory::resolveFactoryName('Modules\\Blog\\Models\\Post')) + ->toBe('Modules\\Blog\\Database\\Factories\\PostFactory'); +}); + +it('keeps the default resolvers without modules', function () { + $this->files(['config/modules.php' => ' base_path("empty")];']); + Factory::flushState(); + $this->reboot(); + + expect(Factory::resolveFactoryName('Modules\\Blog\\Models\\Post')) + ->toBe('Database\\Factories\\Modules\\Blog\\Models\\PostFactory'); +}); + +it('falls back to the App namespace without an application', function () { + $container = Container::getInstance(); + Container::setInstance(new Container); + + try { + expect(Factory::resolveFactoryName('App\\Models\\User'))->toBe('Database\\Factories\\UserFactory'); + } finally { + Container::setInstance($container); + } +}); diff --git a/tests/Feature/Discovery/OctaneWatchTest.php b/tests/Feature/Discovery/OctaneWatchTest.php new file mode 100644 index 0000000..ee2060d --- /dev/null +++ b/tests/Feature/Discovery/OctaneWatchTest.php @@ -0,0 +1,48 @@ +files(['config/octane.php' => ' '.var_export($watch, true).'];']); + $this->reboot(); + + expect(config('octane.watch'))->toBe($expected); +})->with([ + 'appended' => [['app', 'routes'], ['app', 'routes', 'modules']], + 'already watched' => [['modules', 'app'], ['modules', 'app']], + 'empty' => [[], ['modules']], +]); + +it('watches a nested modules directory by its relative path', function () { + $this->files([ + 'config/octane.php' => ' ["app"]];', + 'config/modules.php' => ' base_path("src/modules")];', + ]); + $this->reboot(); + + expect(config('octane.watch'))->toBe(['app', 'src/modules']); +}); + +it('does not configure the watcher when Octane is not configured', function () { + expect(config('octane'))->toBeNull(); +}); + +it('can not watch a modules directory outside of the application', function () { + $this->files([ + 'config/octane.php' => ' ["app"]];', + 'config/modules.php' => ' "'.sys_get_temp_dir().'/elsewhere/modules"];', + ]); + $this->reboot(); + + expect(config('octane.watch'))->toBe(['app']); +}); + +it('can not watch the application directory itself', function () { + $this->files([ + 'config/octane.php' => ' ["app"]];', + 'config/modules.php' => ' base_path()];', + ]); + $this->reboot(); + + expect(config('octane.watch'))->toBe(['app']); +}); diff --git a/tests/Feature/Discovery/TinkerTest.php b/tests/Feature/Discovery/TinkerTest.php new file mode 100644 index 0000000..8db4d2b --- /dev/null +++ b/tests/Feature/Discovery/TinkerTest.php @@ -0,0 +1,21 @@ +toBe(['Modules\\Blog\\', 'Modules\\ShopOrder\\']); +}); + +it('keeps the aliases of the application', function () { + $this->files(['config/tinker.php' => ' ["Vendor\\\\Package\\\\", "Modules\\\\Blog\\\\"]];']); + $this->reboot(); + + expect(config('tinker.alias'))->toBe(['Vendor\\Package\\', 'Modules\\Blog\\', 'Modules\\ShopOrder\\']); +}); + +it('does not add aliases without modules', function () { + $this->files(['config/modules.php' => ' base_path("empty")];']); + $this->reboot(); + + expect(config('tinker.alias'))->toBeNull(); +}); diff --git a/tests/Feature/ModuleRepositoryTest.php b/tests/Feature/ModuleRepositoryTest.php new file mode 100644 index 0000000..1d559ec --- /dev/null +++ b/tests/Feature/ModuleRepositoryTest.php @@ -0,0 +1,153 @@ +toEqual([ + 'blog' => new Module( + name: 'blog', + package: 'app/blog', + namespace: 'Modules\\Blog', + path: $this->path('modules/blog'), + sourcePath: $this->path('modules/blog/src'), + ), + 'shop-order' => new Module( + name: 'shop-order', + package: 'app/shop-order', + namespace: 'Modules\\ShopOrder', + path: $this->path('modules/shop-order'), + sourcePath: $this->path('modules/shop-order/src'), + ), + ]); +}); + +it('finds a module by name', function () { + expect(Modules::find('shop-order')?->package)->toBe('app/shop-order') + ->and(Modules::get('blog')->namespace)->toBe('Modules\\Blog') + ->and(Modules::find('notes'))->toBeNull() + ->and(Modules::find('Blog'))->toBeNull(); +}); + +it('fails to get a missing module', function () { + expect(fn () => Modules::get('notes')) + ->toThrow(ModuleNotFound::class, 'Module [notes] not found.') + ->and(ModuleNotFound::named('notes'))->toBeInstanceOf(ModulesException::class); +}); + +it('uses the module root as the source path when the root namespace maps to it', function () { + $this->files(['modules/flat/composer.json' => json_encode([ + 'name' => 'app/flat', + 'autoload' => ['psr-4' => ['Modules\\Flat\\' => '']], + ])]); + $this->reboot(); + + expect(Modules::get('flat')->sourcePath)->toBe($this->path('modules/flat')); +}); + +it('orders seeders by the number at the end of their class name', function () { + expect(Modules::seeders())->toBe([ + OrderSeeder_1::class, + PostSeeder_2::class, + CategorySeeder_10::class, + BlogSeeder::class, + ]); +}); + +it('collects seeders of the given subdirectories', function (array $directories) { + expect(Modules::seeders(...$directories))->toBe([ + DemoSeeder_1::class, + ShipmentSeeder_1::class, + OrderSeeder_1::class, + PostSeeder_2::class, + CategorySeeder_10::class, + BlogSeeder::class, + ]); +})->with([ + 'root first' => [['', 'Deployment']], + 'root last' => [['Deployment', '']], + 'slashes and duplicates' => [['/Deployment/', 'Deployment', '']], +]); + +it('collects only the seeders of a subdirectory', function () { + expect(Modules::seeders('Deployment'))->toBe([DemoSeeder_1::class, ShipmentSeeder_1::class]) + ->and(Modules::seeders('/Deployment\\'))->toBe([DemoSeeder_1::class, ShipmentSeeder_1::class]) + ->and(Modules::seeders('Missing'))->toBe([]); +}); + +it('builds the manifest once per process', function () { + $manifest = app(ModuleRepository::class)->manifest(); + + $this->files(['modules/wiki/composer.json' => json_encode([ + 'name' => 'app/wiki', + 'autoload' => ['psr-4' => ['Modules\\Wiki\\' => 'src/']], + ])]); + $this->reloadApplication(); + + expect(app(ModuleRepository::class)->manifest())->toBe($manifest) + ->and(Modules::find('wiki'))->toBeNull(); + + app(ModuleRepository::class)->refresh(); + + expect(Modules::find('wiki'))->not->toBeNull(); +}); + +it('keeps separate manifests for separate settings', function () { + app(ModuleRepository::class)->manifest(); + + $this->files([ + 'config/modules.php' => ' base_path("packages")];', + 'packages/wiki/composer.json' => json_encode([ + 'name' => 'app/wiki', + 'autoload' => ['psr-4' => ['Modules\\Wiki\\' => 'src/']], + ]), + ]); + $this->reloadApplication(); + + expect(array_keys(Modules::all()))->toBe(['wiki']); +}); + +it('reads the manifest from the cache', function () { + $modules = app(ModuleRepository::class); + $modules->cache(); + + $this->files(['modules/wiki/composer.json' => json_encode([ + 'name' => 'app/wiki', + 'autoload' => ['psr-4' => ['Modules\\Wiki\\' => 'src/']], + ])]); + $this->reboot(); + $modules = app(ModuleRepository::class); + + expect($modules->isCached())->toBeTrue() + ->and($modules->isCacheStale())->toBeTrue() + ->and(array_keys($modules->all()))->toBe(['blog', 'shop-order']); + + $modules->refresh(); + + expect(array_keys($modules->all()))->toBe(['blog', 'shop-order', 'wiki']) + ->and($modules->isCacheStale())->toBeFalse(); + + $modules->clearCache(); + + expect($modules->isCached())->toBeFalse() + ->and($modules->isCacheStale())->toBeFalse() + ->and(array_keys($modules->all()))->toBe(['blog', 'shop-order', 'wiki']); +}); + +it('does not create the cache on refresh', function () { + $modules = app(ModuleRepository::class); + $modules->refresh(); + + expect($modules->isCached())->toBeFalse(); +}); diff --git a/tests/Feature/ProviderTest.php b/tests/Feature/ProviderTest.php new file mode 100644 index 0000000..23aeffe --- /dev/null +++ b/tests/Feature/ProviderTest.php @@ -0,0 +1,132 @@ +toBe([ + 'providers' => [ModulesServiceProvider::class], + 'aliases' => ['Modules' => Modules::class], + ]) + ->and(app()->getProviders(ModulesServiceProvider::class))->toHaveCount(1) + ->and(\Modules::get('blog')->package)->toBe('app/blog'); +}); + +it('merges its config with the config of the application', function () { + expect(config('modules'))->toBe([ + 'path' => $this->path('modules'), + 'namespace' => 'Modules', + 'vendor' => 'app', + 'routes' => [ + 'api' => ['path' => 'routes/api', 'prefix' => 'api', 'middleware' => ['api']], + 'web' => ['path' => 'routes/web', 'middleware' => ['web']], + ], + 'generators' => [], + ]); + + $this->files(['config/modules.php' => " 'acme'];"]); + $this->reboot(); + + expect(config('modules.vendor'))->toBe('acme') + ->and(config('modules.namespace'))->toBe('Modules'); +}); + +it('publishes the config and the module template', function () { + $published = static fn (string $tag): array => collect(ServiceProvider::pathsToPublish(ModulesServiceProvider::class, $tag)) + ->mapWithKeys(static fn (string $to, string $from): array => [realpath($from) => $to]) + ->all(); + + expect($published('modules-config')) + ->toBe([realpath(__DIR__.'/../../config/modules.php') => $this->path('config/modules.php')]) + ->and($published('modules-stubs')) + ->toBe([realpath(__DIR__.'/../../resources/stubs/module/default') => $this->path('stubs/module/default')]); + + $this->artisan('vendor:publish --tag=modules-stubs')->assertSuccessful(); + + expect(file_exists($this->path('stubs/module/default/composer.json.stub')))->toBeTrue(); +}); + +it('adds its cache to optimize and optimize:clear', function () { + // The framework caches are skipped: they would boot the application from bootstrap/app.php. + $this->artisan('optimize --except=config,events,routes,views') + ->expectsOutputToContain('modules') + ->assertSuccessful(); + + expect(file_exists($this->path('bootstrap/cache/modules.php')))->toBeTrue(); + + $this->artisan('optimize:clear --except=config,cache,compiled,events,routes,views') + ->expectsOutputToContain('modules') + ->assertSuccessful(); + + expect(file_exists($this->path('bootstrap/cache/modules.php')))->toBeFalse(); +}); + +it('shows the modules in the about command', function () { + $about = static function (): array { + Artisan::call('about', ['--only' => 'modules', '--json' => true]); + + return json_decode(Artisan::output(), true, 512, JSON_THROW_ON_ERROR); + }; + + expect($about())->toBe(['modules' => ['modules' => 2, 'path' => 'modules', 'cache' => false]]); + + $this->artisan('module:cache'); + + expect($about())->toBe(['modules' => ['modules' => 2, 'path' => 'modules', 'cache' => true]]); +}); + +it('keeps the HTTP runtime free of console conventions', function () { + $this->rebootForHttp(); + + expect(app()->runningInConsole())->toBeFalse() + ->and(Factory::resolveFactoryName(Post::class))->toBe('Database\\Factories\\Modules\\Blog\\Models\\PostFactory') + ->and(config('tinker.alias'))->toBeNull() + ->and(app('migrator')->paths())->toBe([]) + ->and(Artisan::all())->not->toHaveKeys(['blog:publish', 'module:make']) + ->and(Artisan::all()['make:model']->getDefinition()->hasOption('module'))->toBeFalse(); + + $this->get('/api/posts')->assertOk()->assertSee('blog posts'); +}); + +it('refuses an invalid route group', function (mixed $group) { + $this->files(['config/modules.php' => ' ["api" => '.var_export($group, true).']];']); + + expect(fn () => $this->reboot())->toThrow(InvalidConfiguration::class, 'Invalid config/modules.php: the [api] route group must have a "path".'); +})->with([ + 'no path' => [['prefix' => 'api']], + 'path is not a string' => [['path' => ['routes/api']]], + 'not an array' => ['routes/api'], +]); + +it('refuses a generator namespace that is not a string', function () { + $this->files(['config/modules.php' => ' ["make:controller" => ["Http"]]];']); + + expect(fn () => $this->reboot())->toThrow(InvalidConfiguration::class, 'the namespace of the [make:controller] generator must be a string.'); +}); + +it('stores the manifest cache where MODULES_CACHE points', function (string $path, string $expected) { + $_ENV['MODULES_CACHE'] = $path; + $this->reboot(); + // Registered after the reboot, which destroys the previous application. + $this->beforeApplicationDestroyed(static function (): void { + unset($_ENV['MODULES_CACHE']); + }); + + $this->artisan('module:cache')->assertSuccessful(); + + expect(app(ModuleRepository::class)->cachePath())->toBe(str_replace('{base}', $this->basePath, $expected)) + ->and(file_exists(str_replace('{base}', $this->basePath, $expected)))->toBeTrue(); +})->with([ + 'relative' => ['storage/modules.php', '{base}/storage/modules.php'], + 'absolute' => [fn () => $this->path('storage/framework/modules.php'), '{base}/storage/framework/modules.php'], +]); From daf7813bc617ea3a96ca52645761b70d7a3d3881 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 09:36:54 +0300 Subject: [PATCH 11/38] Test --module on every generator --- tests/Generators/GeneratorsTest.php | 179 ++++++++++++++++++++++++ tests/Generators/GeneratorsTestCase.php | 55 ++++++++ tests/Generators/ModuleOptionTest.php | 106 ++++++++++++++ 3 files changed, 340 insertions(+) create mode 100644 tests/Generators/GeneratorsTest.php create mode 100644 tests/Generators/GeneratorsTestCase.php create mode 100644 tests/Generators/ModuleOptionTest.php diff --git a/tests/Generators/GeneratorsTest.php b/tests/Generators/GeneratorsTest.php new file mode 100644 index 0000000..b183d85 --- /dev/null +++ b/tests/Generators/GeneratorsTest.php @@ -0,0 +1,179 @@ +generate('make:model Comment -mfs --policy --module=blog'); + + expect(array_keys($files))->toEqualCanonicalizing([ + 'modules/blog/src/Models/Comment.php', + 'modules/blog/src/Policies/CommentPolicy.php', + 'modules/blog/database/factories/CommentFactory.php', + 'modules/blog/database/migrations/{date}_create_comments_table.php', + 'modules/blog/database/seeders/CommentSeeder.php', + ]) + ->and($files['modules/blog/src/Models/Comment.php'])->toContain('/** @use HasFactory<\\Modules\\Blog\\Database\\Factories\\CommentFactory> */') + ->and($files['modules/blog/database/factories/CommentFactory.php'])->toContain( + 'namespace Modules\\Blog\\Database\\Factories;', + 'use Modules\\Blog\\Models\\Comment;', + '@extends Factory', + ) + ->and($files['modules/blog/database/seeders/CommentSeeder.php'])->toContain('namespace Modules\\Blog\\Database\\Seeders;') + ->and($files['modules/blog/src/Policies/CommentPolicy.php'])->toContain('use Modules\\Blog\\Models\\Comment;'); +}); + +it('creates the factory of a nested model where the factory resolver looks for it', function () { + $files = $this->generate('make:model Admin/Role --factory --module=blog'); + + expect(array_keys($files))->toEqualCanonicalizing([ + 'modules/blog/src/Models/Admin/Role.php', + 'modules/blog/database/factories/Admin/RoleFactory.php', + ]) + ->and($files['modules/blog/src/Models/Admin/Role.php'])->toContain('HasFactory<\\Modules\\Blog\\Database\\Factories\\Admin\\RoleFactory>') + ->and($files['modules/blog/database/factories/Admin/RoleFactory.php'])->toContain( + 'namespace Modules\\Blog\\Database\\Factories\\Admin;', + 'use Modules\\Blog\\Models\\Admin\\Role;', + ); +}); + +it('registers a module provider in the composer.json of the module', function () { + $this->artisan('make:provider EventServiceProvider --module=blog') + ->expectsOutputToContain('Provider [Modules\\Blog\\Providers\\EventServiceProvider] registered in [modules/blog/composer.json]. Run "php artisan module:sync" to install it.') + ->assertSuccessful(); + + $this->artisan('make:provider EventServiceProvider --module=blog') + ->expectsOutputToContain('Provider already exists.') + ->doesntExpectOutputToContain('registered in') + ->assertSuccessful(); + + expect($this->readJson('modules/blog/composer.json')['extra']) + ->toBe(['laravel' => ['providers' => ['Modules\\Blog\\Providers\\EventServiceProvider']]]) + ->and(file_exists($this->path('modules/blog/src/Providers/EventServiceProvider.php')))->toBeTrue() + ->and(file_exists($this->path('bootstrap/providers.php')))->toBeFalse(); +}); + +it('uses the namespaces of the "generators" config', function (string $command, string $path) { + $this->files(['config/modules.php' => ' ["make:action" => "UI\\\\Actions\\\\", "make:controller" => "Http\\\\Api"]];']); + $this->reboot(); + + expect(array_keys($this->generate($command)))->toBe([$path]); +})->with([ + 'action' => ['make:action PublishPost --module=blog', 'modules/blog/src/UI/Actions/PublishPost.php'], + 'nested name' => ['make:action Posts/PublishPost --module=blog', 'modules/blog/src/UI/Actions/Posts/PublishPost.php'], + 'backslashes' => ['make:action \\\\Posts\\\\PublishPost --module=shop-order', 'modules/shop-order/src/UI/Actions/Posts/PublishPost.php'], + 'fully qualified name' => ['make:action "Modules\\\\Blog\\\\Domain\\\\PublishPost" --module=blog', 'modules/blog/src/Domain/PublishPost.php'], + 'controller' => ['make:controller PostController --module=blog', 'modules/blog/src/Http/Api/PostController.php'], + 'other generators' => ['make:job PublishPost --module=blog', 'modules/blog/src/Jobs/PublishPost.php'], + 'without --module' => ['make:action PublishPost', 'app/Actions/PublishPost.php'], +]); + +it('generates into the application without --module', function (string $command, array $paths) { + expect(array_keys($this->generate($command)))->toEqualCanonicalizing($paths); +})->with([ + 'model' => ['make:model Tag -mfs', [ + 'app/Tag.php', + 'database/factories/TagFactory.php', + 'database/migrations/{date}_create_tags_table.php', + 'database/seeders/TagSeeder.php', + ]], + 'test' => ['make:test PostTest --phpunit', ['tests/Feature/PostTest.php']], + 'view' => ['make:view posts.show --test --phpunit', ['resources/views/posts/show.blade.php', 'tests/Feature/View/Posts/ShowTest.php']], + 'component' => ['make:component Alert', ['app/View/Components/Alert.php', 'resources/views/components/alert.blade.php']], + 'provider' => ['make:provider EventServiceProvider', ['app/Providers/EventServiceProvider.php']], +]); + +it('names the default markdown view of a notification after the module', function () { + $files = $this->generate('make:notification PostPublished --markdown --module=blog'); + + expect($files['modules/blog/src/Notifications/PostPublished.php'])->toContain("->markdown('blog::mail.post-published')") + ->and($files)->toHaveKey('modules/blog/resources/views/mail/post-published.blade.php'); +})->skip(! method_exists(NotificationMakeCommand::class, 'getView'), 'make:notification names a default markdown view since Laravel 13.33.'); + +it('keeps application view names without --module', function () { + $files = $this->generate('make:mail PostPublished --markdown'); + + expect($files['app/Mail/PostPublished.php'])->toContain("markdown: 'mail.post-published'"); +}); + +it('points the application at the module while a generator runs', function () { + $state = static fn (): array => [ + app()->path(), + app()->databasePath(), + app()->configPath(), + app()->getNamespace(), + config('view.paths'), + app(ModuleContext::class)->module(), + ]; + $before = $state(); + $module = Modules::get('blog'); + + $during = app(ModuleContext::class)->run($module, $state); + + expect($during)->toBe([ + $this->path('modules/blog/src'), + $this->path('modules/blog/database'), + $this->path('modules/blog/config'), + 'Modules\\Blog\\', + [$this->path('modules/blog/resources/views')], + $module, + ]) + ->and($state())->toBe($before) + ->and($before[3])->toBe('App\\'); + + expect(fn () => app(ModuleContext::class)->run($module, static fn () => throw new RuntimeException('Generator failed'))) + ->toThrow(RuntimeException::class, 'Generator failed') + ->and($state())->toBe($before); + + Artisan::call('make:model Tag -f --module=blog'); + + expect($state())->toBe($before); +}); + +it('fails for a missing module', function () { + $this->artisan('make:model Tag --module=wiki') + ->expectsOutputToContain('Module [wiki] not found.') + ->assertFailed(); + + expect(file_exists($this->path('app/Models/Tag.php')))->toBeFalse(); +}); + +it('leaves a generator with its own --module option alone', function () { + $command = new class(app('files')) extends GeneratorCommand + { + protected $name = 'make:own-module'; + + protected function getStub(): string + { + return __FILE__; + } + + public function handle(): int + { + $this->line('own module: '.$this->option('module')); + + return self::SUCCESS; + } + + protected function getOptions(): array + { + return [['module', null, InputOption::VALUE_REQUIRED, 'Its own option']]; + } + }; + + ModuleOption::addTo($command); + Artisan::registerCommand($command); + + $this->artisan('make:own-module Thing --module=anything') + ->expectsOutputToContain('own module: anything') + ->assertSuccessful(); + + expect($command->getDefinition()->getOption('module')->getDescription())->toBe('Its own option'); +}); diff --git a/tests/Generators/GeneratorsTestCase.php b/tests/Generators/GeneratorsTestCase.php new file mode 100644 index 0000000..fee394c --- /dev/null +++ b/tests/Generators/GeneratorsTestCase.php @@ -0,0 +1,55 @@ + Contents by path relative to the application. + */ + protected function generate(string $command): array + { + $before = $this->applicationFiles(); + + expect(Artisan::call($command))->toBe(0, Artisan::output()); + + $files = []; + + foreach (array_diff($this->applicationFiles(), $before) as $path) { + $files[(string) preg_replace('/\d{4}_\d{2}_\d{2}_\d{6}_/', '{date}_', $path)] = (string) file_get_contents($this->path($path)); + } + + return $files; + } + + /** + * @return list + */ + private function applicationFiles(): array + { + return collect(File::allFiles($this->basePath, true)) + ->map(static fn (SplFileInfo $file): string => str_replace('\\', '/', $file->getRelativePathname())) + ->reject(static fn (string $path): bool => str_starts_with($path, 'vendor/')) + ->values() + ->all(); + } +} diff --git a/tests/Generators/ModuleOptionTest.php b/tests/Generators/ModuleOptionTest.php new file mode 100644 index 0000000..649729f --- /dev/null +++ b/tests/Generators/ModuleOptionTest.php @@ -0,0 +1,106 @@ +filter(static fn (object $command): bool => $command instanceof GeneratorCommand + || $command instanceof MigrateMakeCommand + || $command instanceof MigrationGeneratorCommand); + + expect($generators->count())->toBeGreaterThan(40) + ->and($generators->reject(static fn (object $command): bool => $command->getDefinition()->hasOption('module'))->keys()->all())->toBe([]) + ->and($generators->keys()->all())->toContain('make:action', 'make:data', 'make:migration', 'make:queue-table'); +}); + +it('generates into the module', function (string $command, array $expected) { + $files = $this->generate($command.' --module=blog'); + + expect(array_keys($files))->toEqualCanonicalizing(array_keys($expected)); + + foreach ($expected as $path => $contains) { + if (str_ends_with($path, '.php') && ! str_ends_with($path, '.blade.php')) { + PhpToken::tokenize($files[$path], TOKEN_PARSE); + } + + expect($files[$path])->toContain(...$contains); + } +})->with([ + 'action' => ['make:action PublishPost', ['modules/blog/src/Actions/PublishPost.php' => ['namespace Modules\\Blog\\Actions;', 'class PublishPost']]], + 'cast' => ['make:cast Money', ['modules/blog/src/Casts/Money.php' => ['namespace Modules\\Blog\\Casts;']]], + 'channel' => ['make:channel PostChannel', ['modules/blog/src/Broadcasting/PostChannel.php' => ['namespace Modules\\Blog\\Broadcasting;']]], + 'class' => ['make:class Support/Slugger', ['modules/blog/src/Support/Slugger.php' => ['namespace Modules\\Blog\\Support;']]], + 'command' => ['make:command SendDigest', ['modules/blog/src/Console/Commands/SendDigest.php' => ['namespace Modules\\Blog\\Console\\Commands;']]], + 'component' => ['make:component Forms/Input', [ + 'modules/blog/src/View/Components/Forms/Input.php' => ['namespace Modules\\Blog\\View\\Components\\Forms;', "view('blog::components.forms.input')"], + 'modules/blog/resources/views/components/forms/input.blade.php' => ['
'], + ]], + 'config' => ['make:config blog-extra', ['modules/blog/config/blog-extra.php' => ['return [']]], + 'controller' => ['make:controller Api/PostController --api --model=Post', ['modules/blog/src/Http/Controllers/Api/PostController.php' => [ + 'namespace Modules\\Blog\\Http\\Controllers\\Api;', + 'use Modules\\Blog\\Models\\Post;', + ]]], + 'data' => ['make:data PostData', ['modules/blog/src/Data/PostData.php' => ['namespace Modules\\Blog\\Data;']]], + 'enum' => ['make:enum Status', ['modules/blog/src/Status.php' => ['namespace Modules\\Blog;', 'enum Status']]], + 'event' => ['make:event PostPublished', ['modules/blog/src/Events/PostPublished.php' => ['namespace Modules\\Blog\\Events;']]], + 'exception' => ['make:exception PostNotFound', ['modules/blog/src/Exceptions/PostNotFound.php' => ['namespace Modules\\Blog\\Exceptions;']]], + 'factory' => ['make:factory CommentFactory --model=Comment', ['modules/blog/database/factories/CommentFactory.php' => [ + 'namespace Modules\\Blog\\Database\\Factories;', + 'use Modules\\Blog\\Models\\Comment;', + ]]], + 'interface' => ['make:interface Contracts/Publisher', ['modules/blog/src/Contracts/Publisher.php' => ['namespace Modules\\Blog\\Contracts;']]], + 'job' => ['make:job PublishPost', ['modules/blog/src/Jobs/PublishPost.php' => ['namespace Modules\\Blog\\Jobs;']]], + 'job middleware' => ['make:job-middleware RateLimited', ['modules/blog/src/Jobs/Middleware/RateLimited.php' => ['namespace Modules\\Blog\\Jobs\\Middleware;']]], + 'listener' => ['make:listener SendNotification --event=PostPublished', ['modules/blog/src/Listeners/SendNotification.php' => [ + 'namespace Modules\\Blog\\Listeners;', + 'use Modules\\Blog\\Events\\PostPublished;', + ]]], + 'mail' => ['make:mail PostPublished --markdown', [ + 'modules/blog/src/Mail/PostPublished.php' => ['namespace Modules\\Blog\\Mail;', "markdown: 'blog::mail.post-published'"], + 'modules/blog/resources/views/mail/post-published.blade.php' => [''], + ]], + 'mail with a view' => ['make:mail PostPublished --view=emails.post', [ + 'modules/blog/src/Mail/PostPublished.php' => ["view: 'blog::emails.post'"], + 'modules/blog/resources/views/emails/post.blade.php' => ['
'], + ]], + 'middleware' => ['make:middleware EnsureAuthor', ['modules/blog/src/Http/Middleware/EnsureAuthor.php' => ['namespace Modules\\Blog\\Http\\Middleware;']]], + 'migration' => ['make:migration create_comments_table', ['modules/blog/database/migrations/{date}_create_comments_table.php' => ["Schema::create('comments'"]]], + 'model' => ['make:model Tag', ['modules/blog/src/Models/Tag.php' => ['namespace Modules\\Blog\\Models;', 'class Tag extends Model']]], + 'notification' => ['make:notification PostPublished --markdown=notifications.post', [ + 'modules/blog/src/Notifications/PostPublished.php' => ['namespace Modules\\Blog\\Notifications;', "->markdown('blog::notifications.post')"], + 'modules/blog/resources/views/notifications/post.blade.php' => [''], + ]], + 'observer' => ['make:observer PostObserver --model=Post', ['modules/blog/src/Observers/PostObserver.php' => [ + 'namespace Modules\\Blog\\Observers;', + 'use Modules\\Blog\\Models\\Post;', + ]]], + 'policy' => ['make:policy PostPolicy --model=Post', ['modules/blog/src/Policies/PostPolicy.php' => [ + 'namespace Modules\\Blog\\Policies;', + 'use Modules\\Blog\\Models\\Post;', + ]]], + 'request' => ['make:request StorePostRequest', ['modules/blog/src/Http/Requests/StorePostRequest.php' => ['namespace Modules\\Blog\\Http\\Requests;']]], + 'resource' => ['make:resource PostCollection --collection', ['modules/blog/src/Http/Resources/PostCollection.php' => ['namespace Modules\\Blog\\Http\\Resources;']]], + 'rule' => ['make:rule Slug', ['modules/blog/src/Rules/Slug.php' => ['namespace Modules\\Blog\\Rules;']]], + 'scope' => ['make:scope PublishedScope', ['modules/blog/src/Models/Scopes/PublishedScope.php' => ['namespace Modules\\Blog\\Models\\Scopes;']]], + 'seeder' => ['make:seeder Deployment/TagSeeder_3', ['modules/blog/database/seeders/Deployment/TagSeeder_3.php' => [ + 'namespace Modules\\Blog\\Database\\Seeders\\Deployment;', + 'class TagSeeder_3 extends Seeder', + ]]], + 'feature test' => ['make:test Api/PostTest --phpunit', ['modules/blog/tests/Feature/Api/PostTest.php' => [ + 'namespace Modules\\Blog\\Tests\\Feature\\Api;', + 'use Tests\\TestCase;', + ]]], + 'unit test' => ['make:test PostTest --unit --phpunit', ['modules/blog/tests/Unit/PostTest.php' => ['namespace Modules\\Blog\\Tests\\Unit;']]], + 'pest test' => ['make:test PostTest --pest', ['modules/blog/tests/Feature/PostTest.php' => ["test('example'"]]], + 'trait' => ['make:trait Concerns/HasSlug', ['modules/blog/src/Concerns/HasSlug.php' => ['namespace Modules\\Blog\\Concerns;', 'trait HasSlug']]], + 'view' => ['make:view posts.show --test --phpunit', [ + 'modules/blog/resources/views/posts/show.blade.php' => ['
'], + 'modules/blog/tests/Feature/View/Posts/ShowTest.php' => ['namespace Modules\\Blog\\Tests\\Feature\\View\\Posts;', "\$this->view('blog::posts.show'"], + ]], + 'queue table' => ['make:queue-table', ['modules/blog/database/migrations/{date}_create_jobs_table.php' => ["Schema::create('jobs'"]]], + 'session table' => ['make:session-table', ['modules/blog/database/migrations/{date}_create_sessions_table.php' => ["Schema::create('sessions'"]]], +]); From f0879e59eb68d5b2711f3ce38b4165d68d50aa09 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 09:36:54 +0300 Subject: [PATCH 12/38] Test the package under Octane and its architecture rules Octane workers serve hundreds of requests with stable state and no retained memory compared to the same application without modules. --- tests/Arch/ArchTest.php | 61 +++++++++ tests/Integration/OctaneTest.php | 220 +++++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 tests/Arch/ArchTest.php create mode 100644 tests/Integration/OctaneTest.php diff --git a/tests/Arch/ArchTest.php b/tests/Arch/ArchTest.php new file mode 100644 index 0000000..95b383d --- /dev/null +++ b/tests/Arch/ArchTest.php @@ -0,0 +1,61 @@ +expect('Laraneat\Modules') + ->toUseStrictTypes(); + +arch('classes are final') + ->expect('Laraneat\Modules') + ->classes() + ->toBeFinal(); + +arch('exceptions are marked') + ->expect('Laraneat\Modules\Exceptions') + ->classes() + ->toImplement('Laraneat\Modules\Exceptions\ModulesException'); + +foreach (['dd', 'dump', 'ray', 'var_dump', 'print_r', 'die', 'exit', 'env', 'getenv', 'putenv'] as $function) { + arch("{$function}() is not used") + ->expect($function) + ->not->toBeUsedIn('Laraneat\Modules'); +} + +foreach (['exec', 'shell_exec', 'system', 'passthru', 'proc_open', 'popen', 'Symfony\Component\Process\Process'] as $process) { + arch("{$process} is not used: processes run through the Process facade") + ->expect($process) + ->not->toBeUsedIn('Laraneat\Modules'); +} + +arch('Composer runs only in the composer runner') + ->expect('Illuminate\Support\Facades\Process') + ->toOnlyBeUsedIn('Laraneat\Modules\Scaffold\ComposerRunner'); + +arch('the package does not depend on Composer internals') + ->expect('Laraneat\Modules') + ->not->toUse('Composer'); + +$runtime = [ + 'Laraneat\Modules\Module', + 'Laraneat\Modules\ModuleRepository', + 'Laraneat\Modules\Facades', + 'Laraneat\Modules\Manifest', + 'Laraneat\Modules\Registrars', +]; + +foreach ($runtime as $namespace) { + foreach ([ + 'Laraneat\Modules\Commands', + 'Laraneat\Modules\Generators', + 'Laraneat\Modules\Scaffold\ApplicationComposer', + 'Laraneat\Modules\Scaffold\ComposerJson', + 'Laraneat\Modules\Scaffold\ComposerRunner', + 'Laraneat\Modules\Scaffold\InstalledPackages', + 'Laraneat\Modules\Scaffold\TemplateRenderer', + ] as $console) { + arch("{$namespace} does not use the console-only {$console}") + ->expect($namespace) + ->not->toUse($console); + } +} diff --git a/tests/Integration/OctaneTest.php b/tests/Integration/OctaneTest.php new file mode 100644 index 0000000..8102dc9 --- /dev/null +++ b/tests/Integration/OctaneTest.php @@ -0,0 +1,220 @@ + */ + public array $statuses = []; + + /** @var array */ + public array $contents = []; + + /** @var list */ + public array $errors = []; + + public function marshalRequest(RequestContext $context): array + { + return [$context->request, $context]; + } + + public function respond(RequestContext $context, OctaneResponse $response): void + { + $status = $response->response->getStatusCode(); + $this->statuses[$status] = ($this->statuses[$status] ?? 0) + 1; + $this->contents[$context->request->getPathInfo()] = (string) $response->response->getContent(); + } + + public function error(Throwable $e, Application $app, Request $request, RequestContext $context): void + { + $this->errors[] = $e->getMessage(); + } +} + +/** + * An application in a temporary directory, with or without the modules provider. Without it, + * the application serves the same routes from its own route files. + */ +function octaneApplication(bool $modules): string +{ + $basePath = TemporaryDirectory::create(__DIR__.'/../Fixtures/app'); + $providers = $modules ? '[Laravel\Octane\OctaneServiceProvider::class, '.ModulesServiceProvider::class.'::class]' : '[Laravel\Octane\OctaneServiceProvider::class]'; + $routes = $modules ? '' : <<<'PHP' + ->withRouting(function () { + Illuminate\Support\Facades\Route::middleware('api')->prefix('api')->group(function () { + Illuminate\Support\Facades\Route::get('posts', fn () => 'blog posts'); + Illuminate\Support\Facades\Route::get('v1/feed', fn () => 'blog feed v1'); + Illuminate\Support\Facades\Route::get('v1/admin/stats', fn () => 'blog stats'); + }); + Illuminate\Support\Facades\Route::middleware('web')->group(function () { + Illuminate\Support\Facades\Route::get('blog', fn () => "Blog: Welcome to the blog\n"); + Illuminate\Support\Facades\Route::get('orders', fn () => 'orders'); + }); + }) + PHP; + + file_put_contents($basePath.'/.env', "APP_KEY=base64:2fl+Ktvkfl+Fuz4Qp/A75G2RTiWVA/ZoKZvp6fiiM10=\nSESSION_DRIVER=array\nCACHE_STORE=array\n"); + file_put_contents($basePath.'/bootstrap/app.php', <<withProviders({$providers}) + {$routes} + ->withMiddleware() + ->withExceptions() + ->create(); + PHP); + + return $basePath; +} + +function octaneRequest(Worker $worker, string $uri): void +{ + $request = Request::create($uri); + + $worker->handle($request, new RequestContext(['request' => $request])); +} + +/** + * Memory kept by the worker after serving the requests. + * + * @param list $uris + */ +function retainedMemory(Worker $worker, array $uris, int $requests): int +{ + gc_collect_cycles(); + $before = memory_get_usage(); + + for ($i = 0; $i < $requests; $i++) { + octaneRequest($worker, $uris[$i % count($uris)]); + } + + gc_collect_cycles(); + + return memory_get_usage() - $before; +} + +beforeEach(function () { + // What vendor/laravel/octane/bin/bootstrap.php does. + $_ENV['APP_RUNNING_IN_CONSOLE'] = $_SERVER['APP_RUNNING_IN_CONSOLE'] = 'false'; + ModuleRepository::flushState(); + $this->basePaths = [$workingDirectory = TemporaryDirectory::create()]; + $this->workingDirectory = (string) getcwd(); + chdir($workingDirectory); +}); + +afterEach(function () { + unset($_ENV['APP_RUNNING_IN_CONSOLE'], $_SERVER['APP_RUNNING_IN_CONSOLE']); + ModuleRepository::flushState(); + HandleExceptions::flushState($this); + Facade::clearResolvedInstances(); + Container::setInstance(null); + chdir($this->workingDirectory); + array_map(TemporaryDirectory::delete(...), $this->basePaths); +}); + +it('serves module routes with the state of the first request', function () { + $client = new StatusClient; + $worker = new Worker(new ApplicationFactory($this->basePaths[] = octaneApplication(modules: true)), $client); + $worker->boot(); + + $snapshots = []; + $worker->onRequestHandled(static function (Request $request, $response, Application $sandbox) use (&$snapshots): void { + $snapshots[] = [ + count($sandbox['router']->getRoutes()), + $sandbox['view']->getFinder()->getHints(), + $sandbox['translation.loader']->namespaces(), + $sandbox['translation.loader']->jsonPaths(), + $sandbox['config']->get('blog'), + $sandbox['config']->get('tinker.alias'), + $sandbox->runningInConsole(), + ]; + }); + + for ($i = 0; $i < 500; $i++) { + octaneRequest($worker, MODULE_URIS[$i % count(MODULE_URIS)]); + } + + expect($client->errors)->toBe([]) + ->and($client->statuses)->toBe([200 => 500]) + ->and($client->contents)->toBe([ + '/api/posts' => 'blog posts', + '/api/v1/feed' => 'blog feed v1', + '/api/v1/admin/stats' => 'blog stats', + '/blog' => "Blog: Welcome to the blog\n", + '/orders' => 'orders', + ]) + ->and(array_unique($snapshots, SORT_REGULAR))->toHaveCount(1) + ->and($snapshots[0][1])->toMatchArray(['blog' => [$this->basePaths[1].'/modules/blog/resources/views']]) + ->and($snapshots[0][3])->toBe([$this->basePaths[1].'/modules/blog/lang']) + ->and($snapshots[0][4])->toBe(['title' => 'Blog', 'per_page' => 15]) + ->and($snapshots[0][5])->toBeNull() + ->and($snapshots[0][6])->toBeFalse(); + + $worker->terminate(); +}); + +it('keeps no more memory than the same routes of the application', function () { + $application = new Worker(new ApplicationFactory($this->basePaths[] = octaneApplication(modules: false)), $applicationClient = new StatusClient); + $application->boot(); + $modules = new Worker(new ApplicationFactory($this->basePaths[] = octaneApplication(modules: true)), $modulesClient = new StatusClient); + $modules->boot(); + + // Laravel and Octane themselves keep some memory per request, so the workers are compared. + retainedMemory($application, MODULE_URIS, 200); + retainedMemory($modules, MODULE_URIS, 200); + $retained = ['application' => 0, 'modules' => 0]; + + for ($round = 0; $round < 3; $round++) { + $retained['application'] += retainedMemory($application, MODULE_URIS, 500); + $retained['modules'] += retainedMemory($modules, MODULE_URIS, 500); + } + + expect($applicationClient->statuses)->toBe([200 => 1700]) + ->and($modulesClient->statuses)->toBe([200 => 1700]) + ->and($modulesClient->contents)->toBe($applicationClient->contents) + ->and($retained['modules'])->toBeLessThan($retained['application'] + 32 * 1024); + + $application->terminate(); + $modules->terminate(); +}); + +it('boots the application again in the same process', function () { + $factory = new ApplicationFactory($this->basePaths[] = octaneApplication(modules: true)); + $routes = static fn (Application $app): array => array_map( + static fn ($route): string => implode('|', $route->methods()).' '.$route->uri(), + $app['router']->getRoutes()->getRoutes(), + ); + + $first = $factory->createApplication(); + $second = $factory->createApplication(); + + expect($routes($second))->toBe($routes($first)) + ->and($routes($first))->toContain('GET|HEAD api/posts', 'GET|HEAD blog', 'GET|HEAD orders') + ->and($routes($first))->toBe(array_values(array_unique($routes($first)))) + ->and($second['config']->get('blog'))->toBe($first['config']->get('blog')); +}); From 8ed4e9cfc87388df6745f9a6b84c5e4ae412f1b6 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 09:36:54 +0300 Subject: [PATCH 13/38] Document 3.0 Rewrite the README for 3.0, add an upgrade guide from 2.x that an agent can follow step by step, a changelog and the license file. --- CHANGELOG.md | 44 +++ LICENSE | 21 + README.md | 1059 +++++++++++++++----------------------------------- UPGRADE.md | 464 ++++++++++++++++++++++ 4 files changed, 833 insertions(+), 755 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 UPGRADE.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..eea674a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## 3.0.0 - Unreleased + +Version 3 is a rewrite. See [UPGRADE.md](UPGRADE.md) for the upgrade from 2.x. + +### Added + +- `--module` for every generator: the Laravel `make:*` commands, `make:migration`, the table migration generators + and the generators of other packages that extend `GeneratorCommand` (`make:action`, `make:data`, ...). +- Module resources are loaded by convention: config, translations, views, Blade components, migrations, + factories, seeders, routes and commands. Modules no longer need their own service provider. +- Route groups in `config/modules.php`; nested route directories are appended to the group prefix. +- The `generators` config maps generator commands to namespaces inside the modules. +- `Modules::seeders()` returns the seeders of all modules, in the order of their `_N` suffix. +- Module templates in `stubs/module/` with `{{ variable|filter }}` placeholders in paths and contents. +- `module:doctor` checks the modules and how they are installed. +- `module:make`, `module:sync` and `module:delete` run Composer themselves; `--no-update` prints the command instead. +- Module vendors are excluded from Packagist, so a missing module is never replaced by a public package. +- The module manifest is cached by `php artisan optimize` and cleared by `optimize:clear`; `MODULES_CACHE` + changes the cache path. +- The modules path is added to `octane.watch`. +- The `about` command shows the modules. + +### Changed + +- Requires PHP 8.3 and Laravel 13.12. +- The facade is `Laraneat\Modules\Facades\Modules`; modules are identified by their directory name. +- `Laraneat\Modules\Module` is a read-only value object with public properties. +- The views and translations namespace of a module is its directory name. +- `module:delete` deletes one module, uninstalls it with Composer before deleting its files, and never deletes + a symlinked module directory. +- Without the cache file, the manifest is built once per process, so it is never stale in development. + +### Removed + +- The `module:make:*` component generators and their stubs, `module:stub:publish` and the `custom_stubs` config. +- `module:migrate` and the `module:migrate:*` commands: use `migrate` and the `migrate:*` commands. +- `Laraneat\Modules\Support\ModuleServiceProvider`, `CanLoadRoutesFromDirectory` and `CanRunModuleSeeders`. +- `InteractsWithTestUser` and `WithJsonResponseHelpers`: UPGRADE.md has versions to copy into the application. +- The `components`, `composer.author`, `user_model`, `create_permission` and `cache` config keys. +- The `composer/composer` dependency. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d595b92 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2021 Salakhutdinov Salavat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index caac0d3..cd87e91 100644 --- a/README.md +++ b/README.md @@ -1,257 +1,46 @@ # Laraneat Modules -A Laravel package that provides a powerful modular architecture system for organizing large-scale applications into self-contained, reusable modules. +Modular monolith infrastructure for Laravel. Every module is a Composer path package in `modules/`; +one service provider loads its config, routes, views, translations, migrations and commands by convention. +Any `make:*` command can generate into a module with `--module`. -## Table of Contents - -- [Overview](#overview) -- [Performance Comparison](#performance-comparison-with-nwidartlaravel-modules) -- [Architecture Diagram](#architecture-diagram) -- [Installation](#installation) -- [Quick Start](#quick-start) -- [Module Structure](#module-structure) -- [Core Concepts](#core-concepts) -- [Configuration](#configuration) -- [Artisan Commands](#artisan-commands) -- [Component Types](#component-types) -- [Module Presets](#module-presets) -- [Best Practices](#best-practices) - -## Overview - -Laraneat Modules helps you build maintainable, scalable Laravel applications. Inspired by the [Porto SAP (Software Architectural Pattern)](https://github.com/Mahmoudz/Porto), it encourages organizing code by business domains (modules) instead of technical layers. - -### Why Modular Architecture? - -| Traditional Laravel | Modular Approach | -|---------------------|------------------| -| All controllers in `app/Http/Controllers` | Each module has its own controllers | -| All models in `app/Models` | Each module has its own models | -| Coupled, hard to maintain | Decoupled, easy to maintain | -| Difficult to reuse | Easy to extract and reuse | -| Complex routing | Module-scoped routing | - -## Performance Comparison with nWidart/laravel-modules - -This package is designed with performance in mind. **With caching enabled, it adds virtually zero overhead** — the cached manifest is a simple PHP array that loads in microseconds, and all core services use lazy loading. - -Here's how it compares to the popular [nWidart/laravel-modules](https://github.com/nWidart/laravel-modules): - -### Key Differences - -| Feature | Laraneat Modules | nWidart/laravel-modules | -|---------|------------------|-------------------------| -| **Module manifest** | `composer.json` only | `module.json` + `composer.json` | -| **Cache type** | Persistent file cache | In-memory only (per request) | -| **Service providers** | DeferrableProvider (lazy) | Eager loading | -| **Enable/disable modules** | Not supported | Supported via JSON file | -| **Architecture pattern** | Domain-driven (Porto-inspired) | Flexible structure | - -### Performance Impact - -#### Production (with cache enabled) - -| Metric | Laraneat | nWidart | -|--------|----------|---------| -| File operations (first request) | 1 (cached manifest) | N (module.json × modules) | -| File operations (subsequent) | 1 | N | -| Providers loaded | On-demand | All modules | - -#### Development (without cache) - -Both packages scan the filesystem on each request. However, Laraneat uses `DeferrableProvider`, so the `ModulesRepository` is only instantiated when actually needed. - -### Why Laraneat is Faster - -1. **Persistent manifest cache** — Module metadata is cached to `bootstrap/cache/laraneat-modules.php`, eliminating filesystem scans in production. - -2. **DeferrableProvider** — Core services (`ModulesRepository`, `Composer`, console commands) implement Laravel's `DeferrableProvider` interface, loading only when requested. - -3. **Single manifest file** — Uses existing `composer.json` instead of requiring an additional `module.json` per module. - -4. **No status file I/O** — No `modules_statuses.json` reads on every request (unlike nWidart's enabled/disabled feature). - -### Recommendations - -```php -// config/modules.php - Enable cache in production -'cache' => [ - 'enabled' => env('APP_ENV') === 'production', -], -``` - -After deployment: ```bash -php artisan module:cache -``` - -For development with many modules, consider enabling cache manually to avoid repeated filesystem scans. - -## Architecture Diagram - -``` -┌──────────────────────────────────────────────────────────────────────────────┐ -│ LARAVEL APPLICATION │ -├──────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌────────────────────────────────────────────────────────────────────────┐ │ -│ │ ModulesRepository │ │ -│ │ - Discovers modules by scanning configured paths │ │ -│ │ - Manages module manifest (cached in production) │ │ -│ │ - Provides find, filter, delete operations │ │ -│ └────────────────────────────────────┬───────────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────┼─────────────────┐ │ -│ │ │ │ │ -│ ▼ ▼ ▼ │ -│ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ │ -│ │ MODULE: Users │ │ MODULE: Articles │ │ MODULE: Orders │ │ -│ │ │ │ │ │ │ │ -│ │ ┌────────────────┐ │ │ ┌────────────────┐ │ │ ┌────────────────┐ │ │ -│ │ │ composer.json │ │ │ │ composer.json │ │ │ │ composer.json │ │ │ -│ │ │ - providers │ │ │ │ - providers │ │ │ │ - providers │ │ │ -│ │ │ - aliases │ │ │ │ - aliases │ │ │ │ - aliases │ │ │ -│ │ │ - namespace │ │ │ │ - namespace │ │ │ │ - namespace │ │ │ -│ │ └────────────────┘ │ │ └────────────────┘ │ │ └────────────────┘ │ │ -│ │ │ │ │ │ │ │ -│ │ src/ │ │ src/ │ │ src/ │ │ -│ │ ├── Actions/ │ │ ├── Actions/ │ │ ├── Actions/ │ │ -│ │ ├── Models/ │ │ ├── Models/ │ │ ├── Models/ │ │ -│ │ ├── Providers/ │ │ ├── Providers/ │ │ ├── Providers/ │ │ -│ │ └── UI/ │ │ └── UI/ │ │ └── UI/ │ │ -│ │ ├── API/ │ │ ├── API/ │ │ ├── API/ │ │ -│ │ ├── WEB/ │ │ ├── WEB/ │ │ ├── WEB/ │ │ -│ │ └── CLI/ │ │ └── CLI/ │ │ └── CLI/ │ │ -│ │ │ │ │ │ │ │ -│ │ database/ │ │ database/ │ │ database/ │ │ -│ │ └── migrations/ │ │ └── migrations/ │ │ └── migrations/ │ │ -│ │ │ │ │ │ │ │ -│ │ tests/ │ │ tests/ │ │ tests/ │ │ -│ └──────────────────────┘ └──────────────────────┘ └──────────────────────┘ │ -│ │ -└──────────────────────────────────────────────────────────────────────────────┘ +php artisan module:make blog +php artisan make:model Post --module=blog -mfs +php artisan make:controller PostController --module=blog --api ``` -### Module Internal Architecture +- **No boilerplate in modules.** A module needs only a `composer.json`. It gets its own service provider only + when it has bindings, policies or events to register. +- **Laravel-native.** Modules use the Laravel generators, stubs and conventions (`Models`, `Http\Controllers`, ...) + unless you map them elsewhere. +- **Fast.** The package scans the modules once and caches the result with `php artisan optimize`. A cached + application does no filesystem scans, and HTTP requests never run the console-only parts. +- **Safe with Composer.** Modules are installed as symlinked path packages; their vendor is excluded from + Packagist, so a missing module can never be replaced by a public package with the same name. -``` -┌───────────────────────────────────────────────────────────┐ -│ MODULE │ -├───────────────────────────────────────────────────────────┤ -│ │ -│ ┌───────────────────── UI LAYER ──────────────────────┐ │ -│ │ │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ -│ │ │ API │ │ WEB │ │ CLI │ │ │ -│ │ │ Controllers │ │ Controllers │ │ Commands │ │ │ -│ │ │ Requests │ │ Requests │ │ │ │ │ -│ │ │ Resources │ │ Views │ │ │ │ │ -│ │ │ Routes │ │ Routes │ │ │ │ │ -│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ -│ │ │ │ │ │ │ -│ └─────────┼────────────────┼────────────────┼─────────┘ │ -│ │ │ │ │ -│ └────────────────┼────────────────┘ │ -│ ▼ │ -│ ┌─────────────────── DOMAIN LAYER ────────────────────┐ │ -│ │ │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ -│ │ │ Actions │ │ DTOs │ │ Events │ │ │ -│ │ │ (Business │ │ (Data │ │ (Domain │ │ │ -│ │ │ Logic) │ │ Transfer) │ │ Events) │ │ │ -│ │ └──────┬──────┘ └─────────────┘ └─────────────┘ │ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ -│ │ │ Models │ │ Observers │ │ Rules │ │ │ -│ │ │ (Entities) │ │ │ │ (Validation)│ │ │ -│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────── INFRASTRUCTURE LAYER ────────────────┐ │ -│ │ │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ -│ │ │ Providers │ │ Middleware │ │ Policies │ │ │ -│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ -│ │ │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ -│ │ │ Mails │ │Notifications│ │ Jobs │ │ │ -│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -│ ┌────────────────── DATABASE LAYER ───────────────────┐ │ -│ │ │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ -│ │ │ Migrations │ │ Seeders │ │ Factories │ │ │ -│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ -│ │ │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -└───────────────────────────────────────────────────────────┘ -``` +## Contents -### Request Flow Through Module - -Actions serve as controllers using the `lorisleiva/laravel-actions` package. Each Action has two entry points: -- `handle()` - Core business logic (can be called from anywhere) -- `asController()` - HTTP entry point (receives Request, returns Response) - -``` -┌──────────┐ ┌───────────┐ ┌────────────┐ -│ HTTP │────▶│ Routes │────▶│ Middleware │ -│ Request │ │ │ │ │ -└──────────┘ └───────────┘ └─────┬──────┘ - │ - ┌───────────────────────┘ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ ACTION │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ asController(Request $request) ◀── HTTP Entry │ │ -│ │ │ │ │ -│ │ ├── $request->toDTO() ─────────▶ DTO │ │ -│ │ │ │ │ -│ │ └── $this->handle($dto) │ │ -│ └─────────────────────┬───────────────────────────────────┘ │ -│ ▼ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ handle(DTO $dto) ◀── Business Logic │ │ -│ │ │ │ │ -│ │ └── Model::create($dto->all()) ──▶ Database │ │ -│ └─────────────────────┬───────────────────────────────────┘ │ -│ │ │ -└────────────────────────┼────────────────────────────────────────┘ - ▼ - ┌────────────────┐ ┌─────────────┐ - │ Resource │────▶│ HTTP │ - │ (Format) │ │ Response │ - └────────────────┘ └─────────────┘ -``` - -**Example Action:** - -```php -class CreatePostAction -{ - use AsAction; - - // Business logic - can be called from anywhere - public function handle(CreatePostDTO $dto): Post - { - return Post::create($dto->all()); - } +- [Requirements](#requirements) +- [Installation](#installation) +- [Modules](#modules) +- [Conventions](#conventions) +- [Routes](#routes) +- [Generators](#generators) +- [Module service providers](#module-service-providers) +- [Seeders](#seeders) +- [Tests](#tests) +- [Commands](#commands) +- [Configuration](#configuration) +- [Performance and caching](#performance-and-caching) +- [Octane](#octane) +- [Upgrading](#upgrading) - // HTTP entry point - acts as controller - public function asController(CreatePostRequest $request): JsonResponse - { - $post = $this->handle($request->toDTO()); +## Requirements - return (new PostResource($post))->created(); - } -} -``` +- PHP 8.3 or newer +- Laravel 13.12 or newer +- Composer 2 ## Installation @@ -259,625 +48,385 @@ class CreatePostAction composer require laraneat/modules ``` -Publish the configuration file: +The service provider and the `Modules` facade are registered by package discovery. Publish the config +when you want to change the defaults: ```bash -php artisan vendor:publish --provider="Laraneat\Modules\Providers\ModulesServiceProvider" +php artisan vendor:publish --tag=modules-config ``` -## Quick Start +## Modules -### 1. Create Your First Module +A module is a directory in `modules/` with a `composer.json`: -```bash -php artisan module:make Blog +```json +{ + "name": "app/blog", + "autoload": { + "psr-4": { + "Modules\\Blog\\": "src/", + "Modules\\Blog\\Database\\Factories\\": "database/factories/", + "Modules\\Blog\\Database\\Seeders\\": "database/seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\Blog\\Tests\\": "tests/" + } + } +} ``` -This creates a new module at `modules/blog/` with basic structure. - -### 2. Create Module with Full API +- The directory name (`blog`) is the module name. It is also the namespace of the module views, + translations and Blade components: `view('blog::index')`, `__('blog::messages.welcome')`, ``. +- The first `autoload.psr-4` entry is the root namespace of the module. +- The module is required by the application like any other package: -```bash -php artisan module:make Blog --preset=api --entity=Post +```json +{ + "require": { + "app/blog": "*@dev" + }, + "repositories": [ + {"type": "path", "url": "modules/*", "options": {"symlink": true}}, + {"type": "composer", "url": "https://repo.packagist.org", "exclude": ["app/*"]}, + {"packagist.org": false} + ] +} ``` -This creates a complete REST API module with: -- Controllers for CRUD operations -- Request validation classes -- API resources for JSON responses -- Database migrations and seeders -- Complete test suite - -### 3. Generate Components - -```bash -# Create a model -php artisan module:make:model Post app/blog - -# Create a controller -php artisan module:make:controller PostController app/blog --ui=api - -# Create a migration -php artisan module:make:migration create_posts_table app/blog +You do not have to write this by hand: `module:make` and `module:sync` maintain it. -# Create an action -php artisan module:make:action CreatePostAction app/blog -``` +- `*@dev` accepts the `dev-*` version of a path package with `"minimum-stability": "stable"`. +- The Packagist repository excludes the vendor of the modules, so Composer fails instead of installing + a public package when a module directory is missing (another branch, a partial checkout). Pick a vendor + that you do not publish public packages under. If you use a Packagist mirror or a private repository + that proxies Packagist, exclude the vendor there as well. -### 4. Run Module Migrations +### Creating a module ```bash -php artisan module:migrate -``` - -## Module Structure - -A complete module follows this structure: - -``` -modules/blog/ -├── composer.json # Module package definition -├── src/ -│ ├── Actions/ # Business logic actions -│ │ ├── CreatePostAction.php -│ │ ├── UpdatePostAction.php -│ │ └── DeletePostAction.php -│ │ -│ ├── Models/ # Eloquent models -│ │ └── Post.php -│ │ -│ ├── DTO/ # Data Transfer Objects -│ │ ├── CreatePostDTO.php -│ │ └── UpdatePostDTO.php -│ │ -│ ├── Events/ # Domain events -│ │ └── PostCreated.php -│ │ -│ ├── Listeners/ # Event listeners -│ │ └── SendPostNotification.php -│ │ -│ ├── Jobs/ # Queued jobs -│ │ └── ProcessPost.php -│ │ -│ ├── Policies/ # Authorization policies -│ │ └── PostPolicy.php -│ │ -│ ├── Providers/ # Service providers -│ │ ├── BlogServiceProvider.php -│ │ └── RouteServiceProvider.php -│ │ -│ └── UI/ -│ ├── API/ -│ │ ├── Controllers/ -│ │ │ └── PostController.php -│ │ ├── Requests/ -│ │ │ ├── CreatePostRequest.php -│ │ │ └── UpdatePostRequest.php -│ │ ├── Resources/ -│ │ │ └── PostResource.php -│ │ ├── QueryWizards/ -│ │ │ └── PostsQueryWizard.php -│ │ └── routes/ -│ │ └── v1.php -│ │ -│ ├── WEB/ -│ │ ├── Controllers/ -│ │ ├── Requests/ -│ │ └── routes/ -│ │ -│ └── CLI/ -│ └── Commands/ -│ -├── database/ -│ ├── migrations/ -│ │ └── 2024_01_01_create_posts_table.php -│ ├── seeders/ -│ │ └── PostSeeder.php -│ └── factories/ -│ └── PostFactory.php -│ -├── resources/ -│ └── views/ -│ -├── lang/ -│ -├── config/ -│ -└── tests/ - ├── Unit/ - ├── Feature/ - └── API/ +php artisan module:make blog +php artisan module:make shop-order --preset=api +php artisan module:make blog --no-update ``` -## Core Concepts +`module:make`: -### Module +1. renders the module template into `modules/blog`; +2. adds the module to the `composer.json` of the application: the path repository, the requirement, + the Packagist exclusion, and the `autoload-dev` entries of the module tests; +3. runs `composer update app/blog`. With `--no-update` it only prints the command, which is useful in CI + or when an agent drives the work. -A **Module** represents a self-contained business domain. It's identified by its Composer package name (e.g., `app/blog`). +The name is normalized to kebab case (`ShopOrder` becomes `shop-order`, namespace `Modules\ShopOrder`). -```php -use Laraneat\Modules\ModulesRepository; - -$repository = app(ModulesRepository::class); +### Module templates -// Find a module -$module = $repository->find('app/blog'); +The built-in template has a `composer.json` and empty convention directories. Your own templates live +in `stubs/module/`: `--preset=api` uses `stubs/module/api`, and `stubs/module/default` replaces the +built-in template. Start from a copy of the built-in one: -// Get module properties -$module->getName(); // "blog" -$module->getStudlyName(); // "Blog" -$module->getNamespace(); // "Modules\Blog" -$module->getPath(); // "/path/to/modules/blog" -$module->getProviders(); // ["Modules\Blog\Providers\BlogServiceProvider"] +```bash +php artisan vendor:publish --tag=modules-stubs ``` -### ModulesRepository - -The **ModulesRepository** discovers and manages all modules in your application. - -```php -use Laraneat\Modules\ModulesRepository; +- Files ending in `.stub` are rendered and lose the extension. Other files are copied as they are. +- Placeholders work in file contents and in paths: `{{ variable }}` or `{{ variable|filter|filter }}`. +- Variables: -$repository = app(ModulesRepository::class); + | Variable | Example value | + |-------------|--------------------------| + | `name` | `article-category` | + | `namespace` | `Modules\ArticleCategory` | + | `package` | `app/article-category` | + | `vendor` | `app` | + | `date` | `2026_09_23_120000` (for migration names) | -// Get all modules -$modules = $repository->getModules(); +- Filters: `studly`, `camel`, `snake`, `kebab`, `plural`, `singular`, `lower`, `upper`, `title`, and `json` + (escapes backslashes for JSON strings). They apply from left to right: `{{ name|plural|snake }}` is + `article_categories`. +- An unknown variable or filter is an error, and nothing is written. +- Rendered paths must stay inside the module directory. +- The template must have a `composer.json` (or `composer.json.stub`), and its package name must match the module. -// Check if module exists -$repository->has('app/blog'); - -// Find module by name -$repository->filterByName('Blog'); - -// Delete a module -$repository->delete('app/blog'); +```text +stubs/module/api/ +├── composer.json.stub +├── routes/api/{{ name }}.php.stub +├── src/Models/{{ name|studly }}.php.stub +└── database/migrations/{{ date }}_create_{{ name|plural|snake }}_table.php.stub ``` -### Actions +## Conventions -**Actions** are the core of the architecture. Using `lorisleiva/laravel-actions`, they serve dual purposes: -- **Business Logic** via `handle()` method - can be called from anywhere (other actions, jobs, commands) -- **HTTP Controller** via `asController()` method - handles HTTP requests directly +The package loads everything it finds in a module. A missing directory is skipped. -```php -// src/Actions/CreatePostAction.php -namespace Modules\Blog\Actions; +| Module path | Loaded as | +|-------------------------------------|------------------------------------------------------------------------| +| `config/*.php` | config keyed by the file name: `config/blog.php` is `config('blog')`. The application config wins. | +| `lang/` | translations: `__('blog::messages.welcome')`, plus JSON files | +| `resources/views/` | views: `view('blog::posts.index')` | +| `src/View/Components/` | Blade components: `` | +| `database/migrations/` | migrations of `php artisan migrate` (console only) | +| `database/factories/` | factories of the module models (console only, see below) | +| `database/seeders/` | seeders of `Modules::seeders()` | +| `routes/api/`, `routes/web/` | [route groups](#routes) | +| `src/Console/Commands/` | Artisan commands (console only) | -use Lorisleiva\Actions\Concerns\AsAction; -use Modules\Blog\DTO\CreatePostDTO; -use Modules\Blog\Models\Post; -use Modules\Blog\UI\API\Requests\CreatePostRequest; -use Modules\Blog\UI\API\Resources\PostResource; -use Illuminate\Http\JsonResponse; +`src/` is the directory of the root namespace of the module: `View/Components` and `Console/Commands` are +looked up there. -class CreatePostAction -{ - use AsAction; +### Factories - // Core business logic - reusable from anywhere - public function handle(CreatePostDTO $dto): Post - { - return Post::create($dto->all()); - } +`Modules\Blog\Models\Post::factory()` uses `Modules\Blog\Database\Factories\PostFactory`, and the factory +knows its model, the way Laravel pairs `App\Models\Post` with `Database\Factories\PostFactory`. Classes outside +the modules keep the Laravel behavior. - // HTTP entry point - acts as controller - public function asController(CreatePostRequest $request): JsonResponse - { - $post = $this->handle($request->toDTO()); - - return (new PostResource($post))->created(); - } -} -``` - -**Routes point directly to Actions:** - -```php -// routes/v1.php -Route::post('/posts', CreatePostAction::class); -Route::get('/posts', ListPostsAction::class); -Route::get('/posts/{post}', ViewPostAction::class); -Route::put('/posts/{post}', UpdatePostAction::class); -Route::delete('/posts/{post}', DeletePostAction::class); -``` - -### DTOs (Data Transfer Objects) - -**DTOs** are simple objects that carry data between layers. +The factory resolver is registered only in the console (tests, seeders, Tinker), because factories are not +used while serving requests. If you create models with factories during HTTP requests, point the model at +its factory: ```php -// src/DTO/CreatePostDTO.php -namespace Modules\Blog\DTO; +use Illuminate\Database\Eloquent\Attributes\UseFactory; -class CreatePostDTO +#[UseFactory(PostFactory::class)] +final class Post extends Model { - public function __construct( - public readonly string $title, - public readonly string $content, - public readonly int $authorId, - ) {} - - public static function fromRequest(CreatePostRequest $request): self - { - return new self( - title: $request->validated('title'), - content: $request->validated('content'), - authorId: $request->user()->id, - ); - } + use HasFactory; } ``` -### Module Service Provider +### Commands -Each module has a service provider that extends `ModuleServiceProvider`: +Classes in the `make:command` namespace of a module (`src/Console/Commands`, including subdirectories) are +registered as Artisan commands. Abstract classes and other classes are skipped. Commands with the +`#[AsCommand]` attribute are loaded lazily. -```php -// src/Providers/BlogServiceProvider.php -namespace Modules\Blog\Providers; +### Policies and events -use Laraneat\Modules\Support\ModuleServiceProvider; +Laravel already finds `Modules\Blog\Policies\PostPolicy` for `Modules\Blog\Models\Post`. Events and +listeners are not discovered: register them in a [module service provider](#module-service-providers). -class BlogServiceProvider extends ModuleServiceProvider -{ - public function boot(): void - { - // Load module commands - $this->loadCommandsFrom([ - 'Modules\\Blog\\UI\\CLI\\Commands' => __DIR__ . '/../UI/CLI/Commands', - ]); +## Routes - // Load migrations - $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); +Route groups are defined in `config/modules.php`: - // Load views - $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'blog'); - } -} +```php +'routes' => [ + 'api' => ['path' => 'routes/api', 'prefix' => 'api', 'middleware' => ['api']], + 'web' => ['path' => 'routes/web', 'middleware' => ['web']], +], ``` -## Configuration - -After publishing, edit `config/modules.php`: +- Every `*.php` file in the `path` directory of a module is loaded inside `Route::group()` with the + other attributes, so `prefix`, `middleware`, `as`, `domain`, `where` and the rest work as usual. +- Nested directories are appended to the prefix: `routes/api/v1/admin/stats.php` is loaded with the + `api/v1/admin` prefix. +- Files of a directory are loaded before its subdirectories, in alphabetical order. +- Groups apply to all modules. A module with its own layout can load its routes in its service provider. +- Nothing is loaded when the routes are cached: `php artisan route:cache` includes the module routes. ```php -return [ - // Where modules are stored - 'path' => base_path('modules'), - - // Base namespace for all modules - 'namespace' => 'Modules', - - // Custom stubs location (optional) - 'custom_stubs' => base_path('stubs/modules'), +// modules/blog/routes/api/posts.php +use Illuminate\Support\Facades\Route; +use Modules\Blog\Http\Controllers\PostController; - // Composer settings for generated modules - 'composer' => [ - 'vendor' => 'app', - 'author' => [ - 'name' => 'Your Name', - 'email' => 'your@email.com', - ], - ], - - // Component path/namespace mappings - 'components' => [ - 'action' => [ - 'path' => 'src/Actions', - 'namespace' => 'Actions', - ], - 'model' => [ - 'path' => 'src/Models', - 'namespace' => 'Models', - ], - // ... more components - ], - - // Enable manifest caching (recommended for production) - 'cache' => [ - 'enabled' => env('APP_ENV') === 'production', - ], -]; +Route::apiResource('posts', PostController::class); // /api/posts ``` -## Artisan Commands +## Generators -### Module Management +Every generator accepts `--module`: all `make:*` commands of Laravel, `make:migration`, the table migrations +(`make:queue-table`, `make:session-table`, ...) and the generators of other packages that extend +`GeneratorCommand` (`make:action` of laravel-actions, `make:data` of laravel-data, ...). -| Command | Description | -|---------|-------------| -| `module:list` | Display all registered modules | -| `module:sync` | Refresh manifest and sync with Composer | -| `module:delete {package}` | Delete a module completely | -| `module:cache` | Build module manifest cache | -| `module:cache:clear` | Clear module manifest cache | - -When a module is added to the application's `composer.json` (by `module:make` or `module:sync`), its vendor is also excluded from Packagist: - -```json -"repositories": [ - { "type": "path", "url": "modules/*", "options": { "symlink": true } }, - { "type": "composer", "url": "https://repo.packagist.org", "exclude": ["app/*"] }, - { "packagist.org": false } -] +```bash +php artisan make:model Post --module=blog -mfs --policy +php artisan make:request StorePostRequest --module=blog +php artisan make:migration create_comments_table --module=blog +php artisan make:test PostTest --module=blog ``` -This way a missing module fails the install instead of silently pulling a same-named package from Packagist. If you configure Packagist yourself (for example with `only`), make sure the modules vendor is not resolved from there. +Classes go where Laravel puts them in the application, relative to the root namespace of the module. +Some generators are adjusted to the module: -### Module Creation +| Command | In the `blog` module | +|------------------------------|-----------------------------------------------------------------------------| +| `make:model` | `src/Models` (if the directory exists, like in Laravel). With `-f`, the factory goes to `database/factories`. | +| `make:factory`, `make:seeder` | `database/factories`, `database/seeders` with the `Modules\Blog\Database\*` namespaces | +| `make:migration` | `database/migrations` | +| `make:test` | `tests/Feature` or `tests/Unit` with the `Modules\Blog\Tests\*` namespaces | +| `make:view`, `make:component`, `make:mail`, `make:notification` | views in `resources/views`, referenced as `blog::...` | +| `make:provider` | `src/Providers`, registered in `extra.laravel.providers` of the module `composer.json` | +| `make:config` | `config` | -```bash -# Create module with interactive preset selection -php artisan module:make Blog +Nested commands run in the same module: `make:model Post --module=blog -mfs` creates the model, the +migration, the factory and the seeder in `blog`. -# Create with specific preset -php artisan module:make Blog --preset=api --entity=Post -``` +### Namespaces -### Component Generators - -| Command | Description | -|---------|-------------| -| `module:make:action` | Create an Action class | -| `module:make:controller` | Create a Controller (--ui=api\|web) | -| `module:make:model` | Create an Eloquent Model | -| `module:make:migration` | Create a database migration | -| `module:make:request` | Create a Form Request | -| `module:make:resource` | Create an API Resource | -| `module:make:dto` | Create a DTO class | -| `module:make:event` | Create an Event class | -| `module:make:listener` | Create an Event Listener | -| `module:make:job` | Create a Job class | -| `module:make:policy` | Create a Policy class | -| `module:make:provider` | Create a Service Provider | -| `module:make:middleware` | Create Middleware | -| `module:make:command` | Create a Console Command | -| `module:make:factory` | Create a Model Factory | -| `module:make:seeder` | Create a Database Seeder | -| `module:make:test` | Create a Test class | -| `module:make:observer` | Create a Model Observer | -| `module:make:notification` | Create a Notification | -| `module:make:mail` | Create a Mailable | -| `module:make:rule` | Create a Validation Rule | -| `module:make:query-wizard` | Create a QueryWizard | -| `module:make:route` | Create a Route file | -| `module:make:exception` | Create an Exception class | - -### Migration Commands - -| Command | Description | -|---------|-------------| -| `module:migrate` | Run module migrations | -| `module:migrate:rollback` | Rollback module migrations | -| `module:migrate:reset` | Reset all module migrations | -| `module:migrate:refresh` | Refresh module migrations | -| `module:migrate:status` | Show migration status | - -## Component Types - -The package supports 30+ component types organized by architectural layers: - -### UI Layer - -**API Components:** -- `ApiController` - REST API controllers -- `ApiRequest` - API form requests -- `ApiResource` - API JSON resources -- `ApiRoute` - API route files -- `ApiQueryWizard` - Query builder wrappers -- `ApiTest` - API integration tests - -**WEB Components:** -- `WebController` - Web controllers -- `WebRequest` - Web form requests -- `WebRoute` - Web route files -- `WebTest` - Web integration tests - -**CLI Components:** -- `CliCommand` - Artisan commands -- `CliTest` - Command tests - -### Domain Layer - -- `Action` - Business logic actions -- `Model` - Eloquent models -- `Dto` - Data Transfer Objects -- `Event` - Domain events -- `Listener` - Event listeners -- `Job` - Queued jobs -- `Rule` - Validation rules -- `Observer` - Model observers - -### Infrastructure Layer - -- `Provider` - Service providers -- `Middleware` - HTTP middleware -- `Policy` - Authorization policies -- `Mail` - Mailable classes -- `Notification` - Notifications - -### Database Layer - -- `Migration` - Database migrations -- `Seeder` - Database seeders -- `Factory` - Model factories - -## Module Presets - -### Plain Preset (Default) - -Basic module with minimal structure: -- Service providers only -- Empty directory structure +To use another layout, map a command to a namespace of the module in `config/modules.php`: -```bash -php artisan module:make Blog --preset=plain +```php +'generators' => [ + 'make:controller' => 'UI\API\Controllers', + 'make:request' => 'UI\API\Requests', + 'make:command' => 'UI\CLI\Commands', +], ``` -### Base Preset +`make:controller PostController --module=blog` then creates `Modules\Blog\UI\API\Controllers\PostController`. +A fully qualified name is used as it is. The `make:command` namespace is also where +[module commands](#commands) are discovered. -Includes database layer components: -- Model with migrations -- Factory for testing -- Seeder with permissions -- Authorization policy - -```bash -php artisan module:make Blog --preset=base --entity=Post -``` +The stubs of the generators are the Laravel ones: customize them with `php artisan stub:publish`. -### API Preset +## Module service providers -Complete REST API module: -- All base preset components -- CRUD controllers -- Form requests (create, update, delete, list, view) -- API resources -- QueryWizard for filtering/sorting -- DTOs for data transfer -- Complete route file -- Full test coverage +A module does not need a service provider. Add one for bindings, event listeners, gates or anything the +conventions do not cover: ```bash -php artisan module:make Blog --preset=api --entity=Post +php artisan make:provider BlogServiceProvider --module=blog +php artisan module:sync ``` -## Best Practices +The provider is registered in `extra.laravel.providers` of the module `composer.json`; `module:sync` installs +the changed module, so that Laravel package discovery picks it up. -### 1. Keep Modules Independent +Module providers are loaded by package discovery, in the order of the package names. The module config +is merged while the Laraneat provider registers, which may happen after the `register()` of a module +provider. Read config in `boot()`, or inside the closures of your bindings, not directly in `register()`. -Modules should be loosely coupled. If module A depends on module B, consider: -- Using events for communication -- Creating shared interfaces -- Moving shared code to a separate package +## Seeders -### 2. Separate HTTP Logic from Business Logic - -Keep `asController()` thin - it should only handle HTTP concerns. Put business logic in `handle()`: +`Modules::seeders()` returns the seeder classes of all modules, ready for `$this->call()`: ```php -class CreatePostAction -{ - use AsAction; +use Laraneat\Modules\Facades\Modules; - // Business logic - reusable, testable - public function handle(CreatePostDTO $dto): Post - { - $post = Post::create($dto->all()); - event(new PostCreated($post)); - - return $post; - } - - // HTTP concerns only - request/response handling - public function asController(CreatePostRequest $request): JsonResponse +final class DatabaseSeeder extends Seeder +{ + public function run(): void { - $post = $this->handle($request->toDTO()); - - return (new PostResource($post))->created(); + $this->call(Modules::seeders()); } } ``` -### 3. Reuse Actions Across Contexts +- Without arguments it returns the seeders of `database/seeders`. Pass subdirectories to add them: + `Modules::seeders('', 'Demo')` returns `database/seeders` and `database/seeders/Demo`, + `Modules::seeders('Demo')` only `database/seeders/Demo`. Subdirectories are not searched recursively. +- Seeders run in the order of their integer `_N` class name suffix (`PermissionsSeeder_1` before + `UsersSeeder_2`); seeders without a suffix run last. Equal suffixes run in the order of the class names. +- Abstract classes and classes that do not extend `Seeder` are skipped. -Actions can be called from multiple places: +## Tests -```php -// From another Action -class ImportPostsAction -{ - public function __construct(private CreatePostAction $createPost) {} +Module tests live in `modules//tests`. `module:make` and `module:sync` add their namespace to the +`autoload-dev` of the application, because Composer does not autoload the `autoload-dev` of dependencies. - public function handle(array $posts): void - { - foreach ($posts as $postData) { - $this->createPost->handle(new CreatePostDTO(...$postData)); - } - } -} +Add the module tests to `phpunit.xml`: -// From a Job -class ProcessImportJob implements ShouldQueue -{ - public function handle(CreatePostAction $action): void - { - $action->handle($this->dto); - } -} +```xml + + tests/Feature + modules/*/tests/Feature + + + tests/Unit + modules/*/tests/Unit + +``` -// From a Command -class SeedPostsCommand extends Command -{ - public function handle(CreatePostAction $action): void - { - $action->handle(new CreatePostDTO(...)); - } -} +With Pest, also extend the test case in `tests/Pest.php`: + +```php +pest()->extend(Tests\TestCase::class)->in('Feature', '../modules/*/tests/Feature'); ``` -### 4. Use DTOs for Data Transfer +## Commands -DTOs provide type safety and clear contracts: +| Command | Description | +|---------------------------------|-------------| +| `module:make ` | Create a module. `--preset=` picks the template, `--no-update` skips `composer update`. | +| `module:sync` | Add every module to the `composer.json` of the application and run `composer update` for the modules that changed. `--no-update` only prints the command. | +| `module:delete ` | Run `composer remove` for the module, then delete its directory. Asks for confirmation; `--force` skips it. `--no-update` only edits `composer.json`. A symlinked module directory is never deleted. | +| `module:doctor` | Check the modules and how they are installed. Read-only; fails when it finds errors. | +| `module:list` | List the modules. With `-v`, also what is loaded from each of them. | +| `module:cache` | Cache the module manifest. Part of `php artisan optimize`. | +| `module:clear` | Remove the module manifest cache. Part of `php artisan optimize:clear`. | -```php -class CreatePostDTO -{ - public function __construct( - public readonly string $title, - public readonly string $content, - public readonly int $authorId, - ) {} +`module:doctor` checks: - public function all(): array - { - return [ - 'title' => $this->title, - 'content' => $this->content, - 'author_id' => $this->authorId, - ]; - } -} -``` +- that every module is required, excluded from Packagist and installed, and that the installed package + links to the module directory; +- that the installed package matches the module `composer.json` (providers, autoload); +- the `autoload-dev` entries of the module tests; +- that the root namespace directory exists and that factories and seeders are autoloaded; +- that the `extra.laravel.providers` classes exist; +- route files outside of the configured route groups; +- unknown keys in `config/modules.php`, an outdated manifest cache, and a modules path that + `octane:start --watch` can not watch. -### 5. Organize Routes by Version +Most problems are fixed by `php artisan module:sync`. -For APIs, version your routes: +The `about` command shows the number of modules, their path and whether the manifest is cached. -``` -routes/ -├── v1.php # Version 1 routes -└── v2.php # Version 2 routes -``` +## Configuration -### 6. Write Tests +```php +return [ + // The directory of the modules. Every subdirectory with a composer.json is a module. + 'path' => base_path('modules'), -Each module should have comprehensive tests: + // The namespace prefix and the Composer vendor of new modules. + 'namespace' => 'Modules', + 'vendor' => 'app', -```bash -# Run all module tests -./vendor/bin/pest modules/blog/tests + // Route groups, see "Routes". + 'routes' => [ + 'api' => ['path' => 'routes/api', 'prefix' => 'api', 'middleware' => ['api']], + 'web' => ['path' => 'routes/web', 'middleware' => ['web']], + ], -# Run specific test -./vendor/bin/pest --filter "can create post" + // Namespaces of the generators inside a module, see "Generators". + 'generators' => [], +]; ``` -### 7. Use Caching in Production +## Performance and caching -Enable manifest caching for better performance: +The package builds a manifest of the modules: their packages, namespaces and the files it loads. The +provider only reads the manifest, it never scans the modules while it boots. -```php -// config/modules.php -'cache' => [ - 'enabled' => env('APP_ENV') === 'production', -], -``` +- `php artisan optimize` (or `module:cache`) writes the manifest to `bootstrap/cache/modules.php`, next to + the config and route caches. A cached application loads it with one `require`. Set `MODULES_CACHE` to + store it elsewhere, like `APP_CONFIG_CACHE`. +- Without the cache file, the manifest is built once per process. There is no stale cache in development. +- `module:make`, `module:sync` and `module:delete` rebuild the cache file if it exists. +- The config and route caches include the module config and routes, so the package skips them when they + are cached. +- Migrations, commands, factories, Tinker aliases and the Octane watcher are registered only in the console. + +Cache the application when you deploy: -Run after deployment: ```bash -php artisan module:cache +php artisan optimize ``` +## Octane + +The package keeps no state that changes after boot, so it is safe for Octane workers. + +`php artisan octane:start --watch` restarts the workers when module files change: the modules path is +added to `octane.watch` automatically, if the modules are inside the application. + +## Upgrading + +See [UPGRADE.md](UPGRADE.md) for the upgrade from 2.x, and [CHANGELOG.md](CHANGELOG.md) for the changes. + ## License -MIT License. See [LICENSE](LICENSE) for details. +The MIT License (MIT). See [LICENSE](LICENSE). diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..069a80d --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,464 @@ +# Upgrade Guide + +## From 2.x to 3.0 + +Version 3 is a smaller package: it loads modules by convention and adds `--module` to the Laravel generators. +The component generators, stubs, migration commands and test helpers of 2.x are gone. + +The steps below are written so that a developer or an AI agent can follow them one by one. Commit after +every step, and run the checks at the end. + +### Before you start + +- The application runs Laravel 13.12 or newer with the latest `laraneat/modules` 2.x, and PHP 8.3 or newer. +- The test suite passes. +- Save the routes of the application to compare them later: + + ```bash + php artisan route:list --json > /tmp/routes-before.json + php artisan module:list + ``` + +### 1. Update the package + +```bash +composer require laraneat/modules:^3.0 --no-update +composer update laraneat/modules --with-all-dependencies --no-scripts +``` + +Artisan does not work again until [step 8](#8-reinstall-the-modules-and-sync-them): the old config and +the module providers use classes that are removed. Steps 2 to 7 only edit files. + +`composer/composer` is no longer a dependency of the package. Keep it only if the application uses it. + +Delete the manifest cache of 2.x, it is not used anymore: + +```bash +rm -f bootstrap/cache/laraneat-modules.php +``` + +### 2. Update the config + +Copy the new config next to the old one and move your values into it: + +```bash +mv config/modules.php config/modules.v2.php +cp vendor/laraneat/modules/config/modules.php config/modules.php +``` + +| 2.x key | 3.0 | +|----------------------------------|------------------------------------------------------------------------------| +| `path`, `namespace` | the same keys | +| `composer.vendor` | `vendor` | +| `components.*` with a namespace | `generators`, see below | +| `components.api-route`, `web-route` | `routes`, see [step 4](#4-move-the-routes-into-route-groups) | +| `components` without a namespace (`config`, `lang`, `migration`, `view`) | fixed: `config`, `lang`, `database/migrations`, `resources/views` | +| `custom_stubs` | removed, see [step 9](#9-replace-the-generators) | +| `composer.author` | removed: put authors in the module templates | +| `user_model`, `create_permission` | removed, see [step 6](#6-copy-the-test-and-response-helpers-into-the-application) | +| `cache.enabled` | removed: the cache is built by `php artisan optimize` | + +`generators` maps a generator command to a namespace inside the module. It replaces the `namespace` of the +2.x components. Map only the commands whose namespace differs from the Laravel one. For the default 2.x +layout: + +```php +'generators' => [ + 'make:controller' => 'UI\API\Controllers', + 'make:request' => 'UI\API\Requests', + 'make:resource' => 'UI\API\Resources', + 'make:command' => 'UI\CLI\Commands', + 'make:data' => 'DTO', + 'make:mail' => 'Mails', + 'make:middleware' => 'Middleware', +], +``` + +One command has one namespace. 2.x had separate API and WEB controllers and requests: generate the other +kind with a fully qualified name, for example +`make:controller "Modules\Blog\UI\WEB\Controllers\PostController" --module=blog`. + +The `make:command` namespace is also where module commands are discovered, so it must match the directory +of the existing commands. + +Delete `config/modules.v2.php` when everything is moved: it references classes that do not exist anymore. + +### 3. Remove the module service providers that only load resources + +The package now loads the resources of every module. For each module in `modules/*`: + +1. Find its providers in `extra.laravel.providers` of `modules//composer.json`. +2. A provider that extends `Laraneat\Modules\Support\ModuleServiceProvider`: + - Remove the `loadConfigurations()`, `loadMigrations()`, `loadCommands()`, `loadTranslations()` and + `loadViews()` methods and their calls. + - If nothing else is left, delete the provider and remove it from `extra.laravel.providers`. + - Otherwise extend `Illuminate\Support\ServiceProvider` instead. `loadCommandsFrom()`, `loadFiles()`, + `loadAllFiles()` and `getPublishableViewPaths()` do not exist anymore. +3. Delete the route service provider that uses `Laraneat\Modules\Support\Concerns\CanLoadRoutesFromDirectory` + and remove it from `extra.laravel.providers`. Keep anything it does besides loading routes (route + patterns, model bindings, rate limiters) in another provider. + +What 3.0 loads, and how it differs from the 2.x providers: + +- **Config**: every `config/*.php` file of the module, keyed by its file name. In 2.x only + `config/.php` was merged, and only when `loadConfigurations()` was called. Check that the other + files of `config/` are meant to be config. The application config still wins; module config files are + not publishable anymore. +- **Views and translations**: the namespace is the module directory name (`blog::`). In 2.x it was the kebab + case name of the module; rename the directory or the references if they differ. Views published into + `resources/views/modules/` do not override the module views anymore: move the changes into the module. +- **Migrations and commands**: loaded in the console, like in 2.x. + +Search for leftovers: + +```bash +grep -rn "Laraneat\\\\Modules\\\\Support\\\\ModuleServiceProvider\|CanLoadRoutesFromDirectory" app modules tests +``` + +### 4. Move the routes into route groups + +Module routes are loaded by the route groups of `config/modules.php`. For the default 2.x layout: + +```php +'routes' => [ + 'api' => ['path' => 'src/UI/API/routes', 'prefix' => 'api', 'middleware' => ['api']], + 'web' => ['path' => 'src/UI/WEB/routes', 'middleware' => ['web']], +], +``` + +Use the prefix, middleware and other attributes of the deleted route service providers. A module whose routes +do not fit the groups loads them in its own service provider with `Route::group()`. + +Two differences from `CanLoadRoutesFromDirectory`: + +- Nested directories are appended to the prefix. In 2.x, `routes/v1/admin/stats.php` got the `api/admin` + prefix; now it gets `api/v1/admin`. Routes one directory deep keep their URIs. +- The files of a directory are loaded before its subdirectories (2.x loaded subdirectories first), and + groups are loaded for all modules in turn: first every `api` route, then every `web` route. + +When two routes can match the same URL (`posts/{post}` and `posts/export`), make sure the specific one is +still registered first. + +### 5. Replace the module seeders trait + +`Laraneat\Modules\Support\Concerns\CanRunModuleSeeders` is replaced by `Modules::seeders()`: + +```php +// Before +use CanRunModuleSeeders; + +$this->runSeedersFromModules(); +$this->runSeedersFromModules('Deployment'); +$this->runSeedersFromModules(['Deployment', 'Demo']); + +// After +use Laraneat\Modules\Facades\Modules; + +$this->call(Modules::seeders()); +$this->call(Modules::seeders('', 'Deployment')); +$this->call(Modules::seeders('', 'Deployment', 'Demo')); +``` + +`runSeedersFromModules($directories)` always included `database/seeders`; with `Modules::seeders()`, `''` is +`database/seeders` and you list it yourself. The order is the same: by the `_N` class name suffix, then by +class name. Abstract classes and classes that are not seeders are skipped now. + +If you overrode methods of the trait (`sortSeederClasses()`, `getSeederClassesFromModule()`), apply the same +logic to the array that `Modules::seeders()` returns. + +### 6. Copy the test and response helpers into the application + +`InteractsWithTestUser` and `WithJsonResponseHelpers` were not related to modules and are removed. Copy them +into the application: + +
+tests/Concerns/InteractsWithTestUser.php + +```php +|null, roles?: string|array|null} + */ + protected array $testUserAccess = [ + 'permissions' => '', + 'roles' => '', + ]; + + protected ?string $testUserGuard = null; + + public function beTestUserWithoutAccess(?array $userDetails = null, ?string $guard = null): static + { + return $this->actingAsTestUserWithoutAccess($userDetails, $guard); + } + + /** + * @param array{permissions?: string|array|null, roles?: string|array|null}|null $access + */ + public function beTestUser(?array $userDetails = null, ?array $access = null, ?string $guard = null): static + { + return $this->actingAsTestUser($userDetails, $access, $guard); + } + + public function actingAsTestUserWithoutAccess(?array $userDetails = null, ?string $guard = null): static + { + return $this->actingAs($this->createTestUserWithoutAccess($userDetails), $guard ?? $this->testUserGuard); + } + + /** + * @param array{permissions?: string|array|null, roles?: string|array|null}|null $access + */ + public function actingAsTestUser(?array $userDetails = null, ?array $access = null, ?string $guard = null): static + { + return $this->actingAs($this->createTestUser($userDetails, $access), $guard ?? $this->testUserGuard); + } + + public function createTestUserWithoutAccess(?array $userDetails = null): Authenticatable + { + return $this->createTestUser($userDetails, []); + } + + /** + * $access replaces $testUserAccess: null gives the default access, [] gives none. + * + * @param array{permissions?: string|array|null, roles?: string|array|null}|null $access + */ + public function createTestUser(?array $userDetails = null, ?array $access = null): Authenticatable + { + $class = $this->testUserClass ?? config('auth.providers.users.model'); + + if (! is_string($class) || ! method_exists($class, 'factory')) { + throw new LogicException('Set $testUserClass to a user model that has a factory.'); + } + + $user = $class::factory()->create($this->testUserAttributes($userDetails ?? [])); + $access ??= $this->testUserAccess; + + if (! empty($access['permissions'])) { + if (! method_exists($user, 'givePermissionTo')) { + throw new LogicException('The user model must use spatie/laravel-permission to give permissions.'); + } + + $user->givePermissionTo($access['permissions']); + } + + if (! empty($access['roles'])) { + if (! method_exists($user, 'assignRole')) { + throw new LogicException('The user model must use spatie/laravel-permission to assign roles.'); + } + + $user->assignRole($access['roles']); + } + + return $user; + } + + /** + * The attributes of a test user. Override it when the user model has other columns. + * + * @param array $details + * @return array + */ + protected function testUserAttributes(array $details): array + { + $attributes = [ + 'name' => 'Testing user', + 'email' => 'testing@test.com', + 'password' => 'testing-password', + ...$details, + ]; + + $attributes['password'] = Hash::make($attributes['password']); + + return $attributes; + } +} +``` + +
+ +
+app/Support/Concerns/WithJsonResponseHelpers.php + +```php + + +Then replace the imports. The calls stay the same: + +```bash +grep -rlZ "Laraneat\\\\Modules\\\\Support\\\\Concerns\\\\\(InteractsWithTestUser\|WithJsonResponseHelpers\)" app modules tests \ + | xargs -0 perl -pi -e 's/Laraneat\\Modules\\Support\\Concerns\\InteractsWithTestUser/Tests\\Concerns\\InteractsWithTestUser/g; s/Laraneat\\Modules\\Support\\Concerns\\WithJsonResponseHelpers/App\\Support\\Concerns\\WithJsonResponseHelpers/g' +``` + +Two changes in `InteractsWithTestUser`: + +- The user model comes from `$testUserClass` or the `users` auth provider, not from `modules.user_model`. +- An explicit empty access now creates a user without access: `actingAsTestUser(null, [])` is the same as + `actingAsTestUserWithoutAccess()`. In 2.x it gave the default access. Find such calls with + `grep -rn "TestUser([^)]*\[\])" modules tests`. + +### 7. Update the code that uses the package API + +- The facade is `Laraneat\Modules\Facades\Modules` (was `Laraneat\Modules\Support\Facades\Modules`). +- Modules are identified by their directory name, not by the package name. + +| 2.x | 3.0 | +|--------------------------------------------|-----------------------------------------------| +| `Modules::getModules()` | `Modules::all()` (keyed by the directory name) | +| `Modules::find('app/blog')` | `Modules::find('blog')` | +| `Modules::findOrFail('app/blog')` | `Modules::get('blog')` | +| `Modules::has('app/blog')` | `Modules::find('blog') !== null` | +| `Modules::count()` | `count(Modules::all())` | +| `Modules::delete()`, `syncWithComposer()` | `php artisan module:delete`, `module:sync` | +| `Modules::buildModulesManifest()`, `pruneModulesManifest()` | `php artisan module:cache`, `module:clear` | +| `$module->getName()`, `getPackageName()`, `getNamespace()`, `getPath()` | `$module->name`, `->package`, `->namespace`, `->path` | +| `$module->subPath('src')`, `subNamespace('Models')` | `$module->path.'/src'`, `$module->namespace.'\\Models'` | + +```bash +grep -rnE 'Laraneat\\Modules\\(Support|Enums|Module;)' app modules tests database routes config bootstrap +``` + +### 8. Reinstall the modules and sync them + +Composer keeps a copy of every module `composer.json` in `vendor/composer/installed.json`, and Laravel +discovers the providers there. Reinstall the modules, so that the deleted providers are forgotten; this also +rebuilds the package manifest, and Artisan works again. Use the vendor of your modules: + +```bash +composer update "app/*" +php artisan module:sync +``` + +`module:sync` excludes the module vendor from Packagist and adds the `autoload-dev` of the module tests. +Existing requirements (`"app/blog": "*"` or `"^1.0"`) are kept. + +### 9. Replace the generators + +The `module:make:*` commands are replaced by the Laravel generators with `--module`: + +| 2.x | 3.0 | +|------------------------------------------------|--------------------------------------------------------------| +| `module:make:model Post blog` | `make:model Post --module=blog` | +| `module:make:controller PostController blog` | `make:controller PostController --module=blog` | +| `module:make:migration create_posts_table blog` | `make:migration create_posts_table --module=blog` | +| `module:make:test PostTest blog` | `make:test PostTest --module=blog` | +| `module:make:action CreatePost blog` | `make:action CreatePost --module=blog` (lorisleiva/laravel-actions) | +| `module:make:dto PostData blog` | `make:data PostData --module=blog` (spatie/laravel-data) | +| the other `module:make:` | `make: ... --module=` | +| `module:make:route`, `module:make:query-wizard` | removed: create the files by hand or with your own generator | +| `module:stub:publish`, `custom_stubs` | `php artisan stub:publish`, which customizes the Laravel stubs | + +The generated code follows the Laravel stubs, not the Porto stubs of 2.x. If you relied on the 2.x stubs, +put your versions in `stubs/` with `php artisan stub:publish`; they apply to the application and to the +modules alike. + +### 10. Replace the migration commands + +Module migrations are registered with the Laravel migrator: + +| 2.x | 3.0 | +|----------------------------------|--------------------------------------------------------------| +| `module:migrate` | `migrate` | +| `module:migrate blog` | `migrate --path=modules/blog/database/migrations` | +| `module:migrate:rollback`, `:refresh`, `:reset`, `:status` | `migrate:rollback`, `migrate:refresh`, `migrate:reset`, `migrate:status` | + +Update scripts, CI jobs and deployment that call the old commands. `module:delete` now deletes one module +at a time, and needs `--force` in non-interactive mode. + +### 11. Replace the module templates + +`module:make --preset=plain|base|api --entity=...` is replaced by module templates: `module:make blog +--preset=` renders `stubs/module/`. Start from the built-in template and add the files your +modules begin with: + +```bash +php artisan vendor:publish --tag=modules-stubs +``` + +The placeholders are described in the [README](README.md#module-templates). `--entity` is gone: use the +module name with filters, for example `{{ name|studly }}`. + +### 12. Clean up + +- Models can drop their `newFactory()` method when the factory is in `database/factories` with the same + name: the package resolves it in the console. Keep `newFactory()` (or add `#[UseFactory]`) for models + that create factories while serving HTTP requests. +- Remove the deployment steps that cleared `bootstrap/cache` to refresh the module cache. Run + `php artisan optimize` when you deploy; it caches the modules together with the config and routes. +- `octane.watch` does not need the modules path anymore; it is added automatically. + +### Check the upgrade + +```bash +php artisan optimize:clear +php artisan module:doctor +php artisan route:list --json > /tmp/routes-after.json +diff <(jq -S . /tmp/routes-before.json) <(jq -S . /tmp/routes-after.json) +php artisan test +php artisan optimize && php artisan optimize:clear +``` + +- `module:doctor` reports no errors. +- The routes are the same, except for the prefixes of nested route directories (step 4). +- The tests pass. From 23201705c34ea458c5556fda65bfa3c8a15979f3 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 09:49:54 +0300 Subject: [PATCH 14/38] Register module resources early and tolerate a stale manifest cache Module providers boot before this package, so config, translations, views and components are registered during register. Cached files of deleted modules are skipped, a relative modules path resolves against the base path and two modules can no longer share a namespace. --- src/Manifest/ManifestBuilder.php | 6 +++ src/ModulesServiceProvider.php | 23 +++++++---- src/Registrars/ResourceRegistrar.php | 34 +++++++++++++---- src/Registrars/RouteRegistrar.php | 5 ++- tests/Feature/CacheTest.php | 12 ++++++ tests/Feature/Conventions/ConfigTest.php | 2 +- .../Feature/Conventions/TranslationsTest.php | 2 +- tests/Feature/Conventions/ViewsTest.php | 24 ++++++++++++ tests/Feature/ProviderTest.php | 38 +++++++++++++++++++ tests/TestCase.php | 10 ++++- tests/Unit/Manifest/ManifestBuilderTest.php | 10 +++++ 11 files changed, 147 insertions(+), 19 deletions(-) diff --git a/src/Manifest/ManifestBuilder.php b/src/Manifest/ManifestBuilder.php index 9864b61..c931807 100644 --- a/src/Manifest/ManifestBuilder.php +++ b/src/Manifest/ManifestBuilder.php @@ -70,6 +70,7 @@ public function build(): array { $manifest = []; $packages = []; + $namespaces = []; foreach ($this->scan($this->modulesPath)[1] as $name) { $path = $this->modulesPath.'/'.$name; @@ -84,7 +85,12 @@ public function build(): array throw InvalidModule::at($path, "the package name [{$module['package']}] is already used by the [{$packages[$module['package']]}] module."); } + if (isset($namespaces[$module['namespace']])) { + throw InvalidModule::at($path, "the namespace [{$module['namespace']}] is already used by the [{$namespaces[$module['namespace']]}] module."); + } + $packages[$module['package']] = $name; + $namespaces[$module['namespace']] = $name; $manifest[$name] = $module; } diff --git a/src/ModulesServiceProvider.php b/src/ModulesServiceProvider.php index 43e4ad6..00a4c03 100644 --- a/src/ModulesServiceProvider.php +++ b/src/ModulesServiceProvider.php @@ -29,11 +29,15 @@ public function register(): void $this->mergeConfigFrom(__DIR__.'/../config/modules.php', 'modules'); $this->app->singleton(ModuleRepository::class, static fn (Application $app): ModuleRepository => new ModuleRepository( - self::manifestBuilder(), + self::manifestBuilder($app), new ManifestCache($app->make('files'), self::cachePath($app), $app->basePath()), )); - (new ResourceRegistrar($this->app, $this->manifest()))->registerConfig(); + (new ResourceRegistrar( + $this->app, + $this->manifest(), + Config::string('modules.generators.make:component', 'View\\Components'), + ))->register(); if ($this->app->runningInConsole()) { $this->app->singleton(ApplicationComposer::class, static fn (Application $app): ApplicationComposer => new ApplicationComposer($app->basePath())); @@ -47,8 +51,6 @@ public function boot(): void { $manifest = $this->manifest(); - (new ResourceRegistrar($this->app, $manifest))->boot(); - if (! $this->app->routesAreCached()) { (new RouteRegistrar($this->app->make('router'), Config::array('modules.routes', []), $manifest))->register(); } @@ -101,7 +103,7 @@ private function manifest(): array return $this->app->make(ModuleRepository::class)->manifest(); } - private static function manifestBuilder(): ManifestBuilder + private static function manifestBuilder(Application $app): ManifestBuilder { $routes = []; $generators = Config::array('modules.generators', []); @@ -120,8 +122,10 @@ private static function manifestBuilder(): ManifestBuilder } } + $path = Config::string('modules.path'); + return new ManifestBuilder( - Config::string('modules.path'), + self::isAbsolute($path) ? $path : $app->basePath($path), $routes, $generators['make:command'] ?? 'Console\\Commands', ); @@ -138,6 +142,11 @@ private static function cachePath(Application $app): string return $app->bootstrapPath('cache/modules.php'); } - return str_starts_with($path, '/') || preg_match('{^[A-Za-z]:[/\\\\]}', $path) === 1 ? $path : $app->basePath($path); + return self::isAbsolute($path) ? $path : $app->basePath($path); + } + + private static function isAbsolute(string $path): bool + { + return str_starts_with($path, '/') || str_starts_with($path, '\\') || preg_match('{^[A-Za-z]:[/\\\\]}', $path) === 1; } } diff --git a/src/Registrars/ResourceRegistrar.php b/src/Registrars/ResourceRegistrar.php index 3eb2a70..fd712c2 100644 --- a/src/Registrars/ResourceRegistrar.php +++ b/src/Registrars/ResourceRegistrar.php @@ -17,6 +17,10 @@ /** * Config, translations, views, Blade components and migrations of the modules. * + * Everything is registered while the package provider registers: module providers may boot + * before it, and they can use the module views and translations. The callbacks run only when + * the translator, the view factory, Blade or the migrator is resolved. + * * Views and translations are added in one callback for all modules, without the * "vendor/" override lookup of loadViewsFrom(): modules belong to the * application. JSON translation paths are added only for modules that have them. @@ -29,17 +33,25 @@ { /** * @param Manifest $manifest + * @param string $componentsNamespace The namespace of Blade components inside a module. */ public function __construct( private Application $app, private array $manifest, + private string $componentsNamespace = 'View\\Components', ) {} + public function register(): void + { + $this->registerConfig(); + $this->registerCallbacks(); + } + /** - * Merge the config files of the modules; the application config wins. Runs in register(), - * so the config is complete before any provider boots. + * Merge the config files of the modules; the application config wins. + * A file missing since the manifest was cached is skipped. */ - public function registerConfig(): void + private function registerConfig(): void { if ($this->app instanceof CachesConfiguration && $this->app->configurationIsCached()) { return; @@ -49,7 +61,11 @@ public function registerConfig(): void foreach ($this->manifest as $module) { foreach ($module['config'] as $key) { - $values = require $module['path'].'/config/'.$key.'.php'; + if (! is_file($file = $module['path'].'/config/'.$key.'.php')) { + continue; + } + + $values = require $file; if (! is_array($values)) { throw InvalidModule::at($module['path'], "config/{$key}.php must return an array."); @@ -60,7 +76,7 @@ public function registerConfig(): void } } - public function boot(): void + private function registerCallbacks(): void { $lang = array_filter($this->manifest, static fn (array $module): bool => $module['lang']); $views = array_filter($this->manifest, static fn (array $module): bool => $module['views']); @@ -86,10 +102,12 @@ public function boot(): void } if ($this->manifest !== []) { - // renders Modules\Blog\View\Components\Alert, or the "blog::components.alert" view. - $this->afterResolving('blade.compiler', function (BladeCompiler $blade): void { + // renders the Alert class of the components namespace, or the "blog::components.alert" view. + $namespace = trim($this->componentsNamespace, '\\'); + + $this->afterResolving('blade.compiler', function (BladeCompiler $blade) use ($namespace): void { foreach ($this->manifest as $name => $module) { - $blade->componentNamespace($module['namespace'].'\\View\\Components', $name); + $blade->componentNamespace($module['namespace'].'\\'.$namespace, $name); } }); } diff --git a/src/Registrars/RouteRegistrar.php b/src/Registrars/RouteRegistrar.php index 3d29cc9..bd6c97b 100644 --- a/src/Registrars/RouteRegistrar.php +++ b/src/Registrars/RouteRegistrar.php @@ -44,7 +44,10 @@ public function register(): void : ['prefix' => ltrim($prefix.'/'.$directory, '/')] + $attributes; foreach ($files as $file) { - $this->router->group($directoryAttributes, $module['path'].'/'.$file); + // A file deleted since the manifest was cached is skipped. + if (is_file($path = $module['path'].'/'.$file)) { + $this->router->group($directoryAttributes, $path); + } } } } diff --git a/tests/Feature/CacheTest.php b/tests/Feature/CacheTest.php index 0e5e418..d437c81 100644 --- a/tests/Feature/CacheTest.php +++ b/tests/Feature/CacheTest.php @@ -36,6 +36,18 @@ ->and(Route::has('blog.posts'))->toBeTrue(); }); +it('skips the files of a module deleted since the cache was written', function () { + $this->artisan('module:cache'); + TemporaryDirectory::delete($this->path('modules/blog')); + $this->reboot(); + + expect(config('blog'))->toBeNull() + ->and(Route::has('blog.posts'))->toBeFalse() + ->and(Route::has('shop-order.orders'))->toBeTrue(); + + $this->artisan('module:clear')->assertSuccessful(); +}); + it('works after the application moved', function () { $this->artisan('module:cache'); $moved = $this->temporaryDirectory($this->basePath); diff --git a/tests/Feature/Conventions/ConfigTest.php b/tests/Feature/Conventions/ConfigTest.php index cecb399..a37bb5d 100644 --- a/tests/Feature/Conventions/ConfigTest.php +++ b/tests/Feature/Conventions/ConfigTest.php @@ -38,7 +38,7 @@ public function boot(): void $this->app->instance('config_loaded_from_cache', true); config(['blog' => null]); - (new ResourceRegistrar($this->app, app(ModuleRepository::class)->manifest()))->registerConfig(); + (new ResourceRegistrar($this->app, app(ModuleRepository::class)->manifest()))->register(); expect($this->app->configurationIsCached())->toBeTrue() ->and(config('blog'))->toBeNull(); diff --git a/tests/Feature/Conventions/TranslationsTest.php b/tests/Feature/Conventions/TranslationsTest.php index 319849a..2f23e48 100644 --- a/tests/Feature/Conventions/TranslationsTest.php +++ b/tests/Feature/Conventions/TranslationsTest.php @@ -40,7 +40,7 @@ $translator = $before ? $app->make('translator') : null; - (new ResourceRegistrar($app, $manifest))->boot(); + (new ResourceRegistrar($app, $manifest))->register(); $translator ??= $app->make('translator'); diff --git a/tests/Feature/Conventions/ViewsTest.php b/tests/Feature/Conventions/ViewsTest.php index a283ccc..658633a 100644 --- a/tests/Feature/Conventions/ViewsTest.php +++ b/tests/Feature/Conventions/ViewsTest.php @@ -38,3 +38,27 @@ public function render(): string expect(Blade::render(' '))->toBe('class badge anonymous card'); }); + +it('registers the Blade components in the namespace of make:component', function () { + $this->files([ + 'config/modules.php' => ' ["make:component" => "\\\\UI\\\\Components\\\\"]];', + 'modules/blog/src/UI/Components/Badge.php' => <<<'PHP' + reboot(); + + expect(Blade::render(''))->toBe('mapped badge'); +}); diff --git a/tests/Feature/ProviderTest.php b/tests/Feature/ProviderTest.php index 23aeffe..865d925 100644 --- a/tests/Feature/ProviderTest.php +++ b/tests/Feature/ProviderTest.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; use Laraneat\Modules\Exceptions\InvalidConfiguration; use Laraneat\Modules\Facades\Modules; @@ -114,6 +115,26 @@ expect(fn () => $this->reboot())->toThrow(InvalidConfiguration::class, 'the namespace of the [make:controller] generator must be a string.'); }); +it('resolves a relative modules path against the application', function () { + $this->files(['config/modules.php' => ' "modules"];']); + chdir(sys_get_temp_dir()); + $this->reboot(); + + expect(array_keys(Modules::all()))->toBe(['blog', 'shop-order']) + ->and(Modules::get('blog')->path)->toBe($this->path('modules/blog')); +}); + +it('lets module providers that boot first use the module translations, views and components', function () { + $this->providersBeforeModules = [ModuleProviderBootingFirst::class]; + $this->reboot(); + + expect(ModuleProviderBootingFirst::$seen)->toBe([ + 'Welcome to the blog', + true, + 'Modules\\Blog\\View\\Components', + ]); +}); + it('stores the manifest cache where MODULES_CACHE points', function (string $path, string $expected) { $_ENV['MODULES_CACHE'] = $path; $this->reboot(); @@ -130,3 +151,20 @@ 'relative' => ['storage/modules.php', '{base}/storage/modules.php'], 'absolute' => [fn () => $this->path('storage/framework/modules.php'), '{base}/storage/framework/modules.php'], ]); + +final class ModuleProviderBootingFirst extends ServiceProvider +{ + /** + * @var list + */ + public static array $seen = []; + + public function boot(): void + { + self::$seen = [ + __('blog::messages.welcome'), + view()->exists('blog::index'), + Blade::getClassComponentNamespaces()['blog'] ?? null, + ]; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 9dce383..1725fac 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -21,6 +21,14 @@ */ abstract class TestCase extends Orchestra { + /** + * Providers registered before the package provider, like module providers of packages + * whose names sort before "laraneat/modules". + * + * @var list + */ + protected array $providersBeforeModules = []; + protected string $basePath; private Closure $autoloader; @@ -89,7 +97,7 @@ protected function getApplicationBasePath(): string */ protected function getPackageProviders($app): array { - return self::composerJson()['extra']['laravel']['providers']; + return [...$this->providersBeforeModules, ...self::composerJson()['extra']['laravel']['providers']]; } protected function getPackageAliases($app): array diff --git a/tests/Unit/Manifest/ManifestBuilderTest.php b/tests/Unit/Manifest/ManifestBuilderTest.php index 4fdc79b..c4a8110 100644 --- a/tests/Unit/Manifest/ManifestBuilderTest.php +++ b/tests/Unit/Manifest/ManifestBuilderTest.php @@ -151,6 +151,16 @@ function moduleComposerJson(string $name, array $psr4 = ['Modules\\Demo\\' => 's ->toThrow(InvalidModule::class, 'the package name [app/blog] is already used by the [blog] module.'); }); +it('rejects two modules with the same namespace', function () { + $modules = $this->files([ + 'blog/composer.json' => moduleComposerJson('app/blog', ['Modules\\Blog\\' => 'src/']), + 'blog-copy/composer.json' => moduleComposerJson('app/blog-copy', ['Modules\\Blog\\' => 'src/']), + ]); + + expect(fn () => manifestBuilder($modules)->build()) + ->toThrow(InvalidModule::class, 'the namespace [Modules\\Blog] is already used by the [blog] module.'); +}); + it('finds config files by their key, top level php files only', function () { $modules = $this->files([ 'demo/composer.json' => moduleComposerJson('app/demo'), From e2067df8e64d7b57334ca76fa443b24f45c6aa28 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 09:52:59 +0300 Subject: [PATCH 15/38] Keep Packagist mirrors and file modes when editing composer.json A mirror under the packagist.org key replaces Packagist, so it gets the vendor exclusion instead of being overwritten, and the default repository is replaced by one entry. The file mode survives a save, modules may be required for development, installed requirement names compare case-insensitively, and module:doctor prunes vendor directories, skips unreadable ones and only warns about a mirrored install. --- README.md | 10 +- src/Commands/DeleteCommand.php | 2 +- src/Commands/DoctorCommand.php | 22 ++- src/Scaffold/ComposerJson.php | 129 +++++++++++------- src/Scaffold/InstalledPackages.php | 11 +- tests/Feature/Commands/DoctorCommandTest.php | 33 ++++- tests/Feature/Commands/MakeCommandTest.php | 1 - tests/Feature/Commands/SyncCommandTest.php | 1 - tests/Unit/Scaffold/ComposerJsonTest.php | 63 +++++++-- tests/Unit/Scaffold/InstalledPackagesTest.php | 2 + 10 files changed, 186 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index cd87e91..47ede31 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,7 @@ A module is a directory in `modules/` with a `composer.json`: }, "repositories": [ {"type": "path", "url": "modules/*", "options": {"symlink": true}}, - {"type": "composer", "url": "https://repo.packagist.org", "exclude": ["app/*"]}, - {"packagist.org": false} + {"type": "composer", "url": "https://repo.packagist.org", "exclude": ["app/*"]} ] } ``` @@ -99,9 +98,10 @@ You do not have to write this by hand: `module:make` and `module:sync` maintain - `*@dev` accepts the `dev-*` version of a path package with `"minimum-stability": "stable"`. - The Packagist repository excludes the vendor of the modules, so Composer fails instead of installing - a public package when a module directory is missing (another branch, a partial checkout). Pick a vendor - that you do not publish public packages under. If you use a Packagist mirror or a private repository - that proxies Packagist, exclude the vendor there as well. + a public package when a module directory is missing (another branch, a partial checkout). Composer uses + this repository in place of the default one. Pick a vendor that you do not publish public packages under. +- A mirror that replaces Packagist under the `packagist.org` key of `"repositories": {...}` gets the + exclusion too. Exclude the vendor by hand in any other repository that proxies Packagist. ### Creating a module diff --git a/src/Commands/DeleteCommand.php b/src/Commands/DeleteCommand.php index 5fde146..13ac198 100644 --- a/src/Commands/DeleteCommand.php +++ b/src/Commands/DeleteCommand.php @@ -57,7 +57,7 @@ public function handle( // Uninstall first: when Composer fails, the module and composer.json are left as they were. if (! $this->option('no-update') && ! $runner->remove([$module->package], $this->streamOutput(...))) { - $files->replace($composer->composerJsonPath(), $original); + $files->put($composer->composerJsonPath(), $original); $this->components->error('Composer failed to remove the module, nothing was deleted.'); return self::FAILURE; diff --git a/src/Commands/DoctorCommand.php b/src/Commands/DoctorCommand.php index 73382c6..6097425 100644 --- a/src/Commands/DoctorCommand.php +++ b/src/Commands/DoctorCommand.php @@ -14,6 +14,7 @@ use Laraneat\Modules\Scaffold\ApplicationComposer; use Laraneat\Modules\Scaffold\ComposerJson; use Laraneat\Modules\Scaffold\InstalledPackages; +use RecursiveCallbackFilterIterator; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use SplFileInfo; @@ -112,8 +113,11 @@ private function checkModule(Module $module, array $manifest, ComposerJson $root if (! $installed->has($module->package)) { $problems[] = [true, "[{$module->package}] is not installed: run \"php artisan module:sync\"."]; - } elseif (realpath($installed->path($module->package)) !== realpath($module->path)) { - $problems[] = [true, "[{$composer->relative($installed->path($module->package))}] is not a link to [{$composer->relative($module->path)}]."]; + } elseif (realpath($vendorPath = $installed->path($module->package)) !== realpath($module->path)) { + // COMPOSER_MIRROR_PATH_REPOS=1 copies path packages, as Docker images often do. + $problems[] = is_dir($vendorPath) && ! is_link($vendorPath) + ? [false, "[{$composer->relative($vendorPath)}] is a copy, not a link to [{$composer->relative($module->path)}]: changes of the module need \"composer update\"."] + : [true, "[{$composer->relative($vendorPath)}] is not a link to [{$composer->relative($module->path)}]."]; } elseif (! $installed->isCurrent($module->package, $composerJson->toArray())) { $problems[] = [false, 'composer.json changed since the module was installed: run "php artisan module:sync".']; } @@ -168,17 +172,21 @@ private function unloadedRouteFiles(Module $module, array $manifest): array ))); $files = []; - $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($module->path, FilesystemIterator::SKIP_DOTS)); + $iterator = new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator($module->path, FilesystemIterator::SKIP_DOTS), + static fn (SplFileInfo $file): bool => ! $file->isDir() || preg_match('{^(vendor|node_modules|tests|\..*)$}', $file->getFilename()) !== 1, + ), + RecursiveIteratorIterator::LEAVES_ONLY, + RecursiveIteratorIterator::CATCH_GET_CHILD, + ); $iterator->setMaxDepth(6); /** @var SplFileInfo $file */ foreach ($iterator as $file) { $path = substr(str_replace('\\', '/', $file->getPathname()), strlen($module->path) + 1); - if ($file->getExtension() === 'php' - && preg_match('{(^|/)routes/}', $path) === 1 - && preg_match('{(^|/)(vendor|node_modules|tests|\.[^/]*)/}', $path) !== 1 - && ! in_array($path, $loaded, true)) { + if ($file->getExtension() === 'php' && preg_match('{(^|/)routes/}', $path) === 1 && ! in_array($path, $loaded, true)) { $files[] = $path; } } diff --git a/src/Scaffold/ComposerJson.php b/src/Scaffold/ComposerJson.php index dc41686..d498ab4 100644 --- a/src/Scaffold/ComposerJson.php +++ b/src/Scaffold/ComposerJson.php @@ -12,7 +12,8 @@ /** * Edits a composer.json file. The JSON is decoded to objects, so "{}" stays "{}", - * and written back with the original indentation, atomically, only when it changed. + * and written back atomically, only when it changed. A changed file is re-encoded + * as pretty-printed JSON with its original indentation. * * @internal */ @@ -81,12 +82,11 @@ public function toArray(): array public function addModule(string $package, string $repository, array $autoloadDev): self { $this->addPathRepository($repository); - $this->excludeFromPackagist(strstr($package, '/', true).'/*'); - - $require = $this->object($this->data, 'require'); + $this->excludeFromPackagist($package); // A path package has a "dev-*" version, which "minimum-stability": "stable" refuses without "@dev". - if (! isset($require->{$package})) { + if (! $this->requires($package)) { + $require = $this->object($this->data, 'require'); $require->{$package} = '*@dev'; if ($this->get('config.sort-packages') === true) { @@ -107,8 +107,10 @@ public function addModule(string $package, string $repository, array $autoloadDe public function removeRequire(string $package): self { - if (($this->data->require ?? null) instanceof stdClass) { - unset($this->data->require->{$package}); + foreach (['require', 'require-dev'] as $key) { + if (($this->data->{$key} ?? null) instanceof stdClass) { + unset($this->data->{$key}->{$package}); + } } return $this; @@ -146,11 +148,20 @@ public function addProvider(string $provider): self return $this; } + /** + * Whether "require" or "require-dev" has the package. + */ public function requires(string $package): bool { - $require = $this->data->require ?? null; + foreach (['require', 'require-dev'] as $key) { + $require = $this->data->{$key} ?? null; + + if ($require instanceof stdClass && is_string($require->{$package} ?? null)) { + return true; + } + } - return $require instanceof stdClass && is_string($require->{$package} ?? null); + return false; } /** @@ -158,17 +169,17 @@ public function requires(string $package): bool */ public function isExcludedFromPackagist(string $package): bool { - foreach ($this->repositories() as $repository) { - if ($this->isPackagist($repository)) { - if (is_array($repository->only ?? null)) { - return ! $this->matches($package, $repository->only); - } + $packagist = $this->packagist(); - return $this->matches($package, is_array($repository->exclude ?? null) ? $repository->exclude : []); - } + if (! $packagist instanceof stdClass) { + return $packagist === false; } - return $this->isPackagistDisabled(); + if (is_array($packagist->only ?? null)) { + return ! $this->matches($package, $packagist->only); + } + + return $this->matches($package, is_array($packagist->exclude ?? null) ? $packagist->exclude : []); } public function isDirty(): bool @@ -187,7 +198,10 @@ public function contents(): string public function save(): void { if ($this->isDirty()) { - (new Filesystem)->replace($this->path, $this->saved = $this->contents()); + // Without a mode, replace() makes the file executable. + $mode = fileperms($this->path); + + (new Filesystem)->replace($this->path, $this->saved = $this->contents(), $mode === false ? null : $mode & 0777); } } @@ -207,64 +221,75 @@ private function addPathRepository(string $url): void * directory (another branch, a partial checkout) makes Composer install a package with * the same name from Packagist instead of failing. */ - private function excludeFromPackagist(string $pattern): void + private function excludeFromPackagist(string $package): void { - foreach ($this->repositories() as $repository) { - if (! $this->isPackagist($repository)) { - continue; - } + $packagist = $this->packagist(); + + if ($packagist === false) { + return; + } - // A repository restricted with "only" does not serve the vendor anyway. - if (! isset($repository->only)) { - $exclude = is_array($repository->exclude ?? null) ? $repository->exclude : []; + $pattern = strstr($package, '/', true).'/*'; - if (! in_array($pattern, $exclude, true)) { - $repository->exclude = [...$exclude, $pattern]; - } - } + if ($packagist === null) { + // Composer replaces the default Packagist repository with a repository of its URL. + $this->addRepository('packagist.org', (object) ['type' => 'composer', 'url' => self::PACKAGIST, 'exclude' => [$pattern]]); return; } - if ($this->isPackagistDisabled()) { + // Composer does not accept "only" and "exclude" together. + if (is_array($packagist->only ?? null)) { + if ($this->matches($package, $packagist->only)) { + throw ComposerFailed::because("[{$this->path}] Packagist serves [{$package}] through \"only\": remove the pattern that matches it."); + } + return; } - $this->addRepository('packagist', (object) ['type' => 'composer', 'url' => self::PACKAGIST, 'exclude' => [$pattern]]); + $exclude = is_array($packagist->exclude ?? null) ? $packagist->exclude : []; - if ($this->data->repositories instanceof stdClass) { - $this->data->repositories->{'packagist.org'} = false; - } else { - $this->addRepository('packagist.org', (object) ['packagist.org' => false]); + if (! in_array($pattern, $exclude, true)) { + $packagist->exclude = [...$exclude, $pattern]; } } /** - * Composer replaces the default Packagist repository with a repository of this URL. - * - * @phpstan-assert-if-true stdClass $repository + * The repository Composer uses in place of the default Packagist repository: a composer repository + * with a packagist.org URL, or one named "packagist.org" or "packagist" whatever its URL (a mirror). + * False when Packagist is disabled, null for the default repository. */ - private function isPackagist(mixed $repository): bool + private function packagist(): stdClass|false|null { - return $repository instanceof stdClass - && ($repository->type ?? null) === 'composer' - && is_string($repository->url ?? null) - && preg_match('{^https?://(?:[a-z0-9-.]+\.)?packagist\.org(/|$)}', $repository->url) === 1; - } + $disabled = false; - private function isPackagistDisabled(): bool - { foreach ($this->repositories() as $name => $repository) { - if (in_array($name, ['packagist', 'packagist.org'], true) && $repository === false) { - return true; + if (is_string($name) && in_array($name, ['packagist', 'packagist.org'], true)) { + if ($repository === false) { + $disabled = true; + + continue; + } + + if ($repository instanceof stdClass && ($repository->type ?? null) === 'composer') { + return $repository; + } } - if ($repository instanceof stdClass && (($repository->{'packagist.org'} ?? null) === false || ($repository->packagist ?? null) === false)) { - return true; + if (! $repository instanceof stdClass) { + continue; + } + + if (($repository->{'packagist.org'} ?? null) === false || ($repository->packagist ?? null) === false) { + $disabled = true; + } elseif (($repository->type ?? null) === 'composer' + && is_string($repository->url ?? null) + && preg_match('{^https?://(?:[a-z0-9-.]+\.)?packagist\.org(/|$)}', $repository->url) === 1) { + return $repository; } } - return false; + return $disabled ? false : null; } /** diff --git a/src/Scaffold/InstalledPackages.php b/src/Scaffold/InstalledPackages.php index d8b6d48..64fdc3a 100644 --- a/src/Scaffold/InstalledPackages.php +++ b/src/Scaffold/InstalledPackages.php @@ -52,13 +52,12 @@ public function isCurrent(string $package, array $composerJson): bool return false; } - foreach (['require', 'autoload', 'extra'] as $key) { - if (($installed[$key] ?? []) != ($composerJson[$key] ?? [])) { - return false; - } - } + // Composer lowercases the package names of the installed requirements. + $require = is_array($composerJson['require'] ?? null) ? array_change_key_case($composerJson['require']) : []; - return true; + return ($installed['require'] ?? []) == $require + && ($installed['autoload'] ?? []) == ($composerJson['autoload'] ?? []) + && ($installed['extra'] ?? []) == ($composerJson['extra'] ?? []); } /** diff --git a/tests/Feature/Commands/DoctorCommandTest.php b/tests/Feature/Commands/DoctorCommandTest.php index debb3b8..c6ce89c 100644 --- a/tests/Feature/Commands/DoctorCommandTest.php +++ b/tests/Feature/Commands/DoctorCommandTest.php @@ -35,10 +35,10 @@ function doctor(): array ); }); -it('reports a package installed from elsewhere', function () { +it('reports a package linked elsewhere', function () { $this->installModules(); unlink($this->path('vendor/app/blog')); - mkdir($this->path('vendor/app/blog')); + symlink($this->path('modules/shop-order'), $this->path('vendor/app/blog')); [$status, $output] = doctor(); @@ -46,6 +46,17 @@ function doctor(): array ->and($output)->toContain('✗ [vendor/app/blog] is not a link to [modules/blog].'); }); +it('warns about a mirrored package', function () { + $this->installModules(); + unlink($this->path('vendor/app/blog')); + mkdir($this->path('vendor/app/blog')); + + [$status, $output] = doctor(); + + expect($status)->toBe(0) + ->and($output)->toContain('! [vendor/app/blog] is a copy, not a link to [modules/blog]: changes of the module need "composer update".'); +}); + it('reports outdated installed metadata', function () { $this->installModules(); $this->files(['modules/blog/composer.json' => json_encode([...$this->readJson('modules/blog/composer.json'), 'require' => ['php' => '^8.4']])]); @@ -69,6 +80,8 @@ function doctor(): array 'modules/blog/routes/admin/users.php' => ' ' ' ' 'reboot(); @@ -84,9 +97,23 @@ function doctor(): array " ! The route file [routes/admin/users.php] is not in a route group of config/modules.php.\n ! The route file [routes/zeta/users.php] is not in a route group of config/modules.php.", ) ->toMatch('/^ blog \\.+ FAIL$/m') - ->not->toContain('secret.php', 'fixture.php', 'routes/api/posts.php'); + ->not->toContain('secret.php', 'fixture.php', 'lib/routes', 'routes/api/posts.php'); }); +it('skips directories it can not read', function () { + $this->installModules(); + $this->files(['modules/blog/private/routes/web.php' => 'path('modules/blog/private'), 0); + + try { + [$status, $output] = doctor(); + } finally { + chmod($this->path('modules/blog/private'), 0755); + } + + expect($status)->toBe(0)->and($output)->toContain('No problems found.'); +})->skip(fn (): bool => PHP_OS_FAMILY === 'Windows' || posix_geteuid() === 0, 'Permissions are not enforced.'); + it('accepts equivalent autoload paths and skips directories a module does not have', function () { // shop-order has no database/factories directory. $composerJson = $this->readJson('modules/shop-order/composer.json'); diff --git a/tests/Feature/Commands/MakeCommandTest.php b/tests/Feature/Commands/MakeCommandTest.php index f156a7f..33a9629 100644 --- a/tests/Feature/Commands/MakeCommandTest.php +++ b/tests/Feature/Commands/MakeCommandTest.php @@ -60,7 +60,6 @@ 'repositories' => [ ['type' => 'path', 'url' => 'modules/*', 'options' => ['symlink' => true]], ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['app/*']], - ['packagist.org' => false], ], 'autoload-dev' => ['psr-4' => ['Modules\\WikiPages\\Tests\\' => 'modules/wiki-pages/tests/']], ]) diff --git a/tests/Feature/Commands/SyncCommandTest.php b/tests/Feature/Commands/SyncCommandTest.php index a4b0557..d32d86a 100644 --- a/tests/Feature/Commands/SyncCommandTest.php +++ b/tests/Feature/Commands/SyncCommandTest.php @@ -23,7 +23,6 @@ ->repositories->toBe([ ['type' => 'path', 'url' => 'modules/*', 'options' => ['symlink' => true]], ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['app/*']], - ['packagist.org' => false], ]) ->{'autoload-dev'}->toBe(['psr-4' => ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']]); }); diff --git a/tests/Unit/Scaffold/ComposerJsonTest.php b/tests/Unit/Scaffold/ComposerJsonTest.php index 51b0af8..79c332c 100644 --- a/tests/Unit/Scaffold/ComposerJsonTest.php +++ b/tests/Unit/Scaffold/ComposerJsonTest.php @@ -36,7 +36,7 @@ function addBlog(ComposerJson $composerJson): void expect($json)->toBe([ 'name' => 'demo/app', 'require' => ['php' => '^8.3', 'app/blog' => '*@dev'], - 'repositories' => [PATH_REPOSITORY, PACKAGIST_EXCLUDING_APP, ['packagist.org' => false]], + 'repositories' => [PATH_REPOSITORY, PACKAGIST_EXCLUDING_APP], 'autoload-dev' => ['psr-4' => ['Modules\\Blog\\Tests\\' => 'modules/blog/tests/']], ]); }); @@ -59,7 +59,6 @@ function addBlog(ComposerJson $composerJson): void ->and($json['repositories'])->toBe([ PATH_REPOSITORY, ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'exclude' => ['app/*', 'acme/*']], - ['packagist.org' => false], ]); }); @@ -100,7 +99,7 @@ function addBlog(ComposerJson $composerJson): void 'repositories' => [['type' => 'path', 'url' => 'modules/*']], ], addBlog(...)); - expect($json['repositories'])->toBe([['type' => 'path', 'url' => 'modules/*'], PACKAGIST_EXCLUDING_APP, ['packagist.org' => false]]); + expect($json['repositories'])->toBe([['type' => 'path', 'url' => 'modules/*'], PACKAGIST_EXCLUDING_APP]); }); it('adds the path repository when others point elsewhere', function () { @@ -128,8 +127,7 @@ function addBlog(ComposerJson $composerJson): void ]); })->with(['https://repo.packagist.org', 'https://repo.packagist.org/', 'https://packagist.org', 'http://repo.packagist.org/packages']); -it('does not disable the default packagist repository again when another one replaces it', function () { - // Composer disables the default repository when a repository has a packagist.org URL. +it('adds the filter to a packagist repository without one', function () { $json = editComposerJson($this->directory, ['repositories' => [['type' => 'composer', 'url' => 'https://repo.packagist.org']]], addBlog(...)); expect($json['repositories'])->toBe([PACKAGIST_EXCLUDING_APP, PATH_REPOSITORY]); @@ -140,9 +138,21 @@ function addBlog(ComposerJson $composerJson): void $json = editComposerJson($this->directory, ['repositories' => [$mirror]], addBlog(...)); - expect($json['repositories'])->toBe([$mirror, PATH_REPOSITORY, PACKAGIST_EXCLUDING_APP, ['packagist.org' => false]]); + expect($json['repositories'])->toBe([$mirror, PATH_REPOSITORY, PACKAGIST_EXCLUDING_APP]); }); +it('filters a mirror that replaces packagist by its name', function (string $name) { + $mirror = ['type' => 'composer', 'url' => 'https://mirror.example.com']; + + $json = editComposerJson($this->directory, ['repositories' => [$name => $mirror, 'private' => ['type' => 'vcs', 'url' => 'x']]], addBlog(...)); + + expect($json['repositories'])->toBe([ + $name => [...$mirror, 'exclude' => ['app/*']], + 'private' => ['type' => 'vcs', 'url' => 'x'], + 'modules' => PATH_REPOSITORY, + ]); +})->with(['packagist.org', 'packagist']); + it('leaves a packagist repository restricted with "only" untouched', function () { $repository = ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'only' => ['laravel/*']]; @@ -151,6 +161,13 @@ function addBlog(ComposerJson $composerJson): void expect($json['repositories'])->toBe([$repository, ['packagist.org' => false], PATH_REPOSITORY]); }); +it('fails when packagist serves the module through "only"', function () { + $contents = ['repositories' => [['type' => 'composer', 'url' => 'https://repo.packagist.org', 'only' => ['laravel/*', 'app/*']]]]; + + expect(fn () => editComposerJson($this->directory, $contents, addBlog(...))) + ->toThrow(ComposerFailed::class, 'Packagist serves [app/blog] through "only"'); +}); + it('does not enable packagist when it is disabled', function (array $disabled) { $json = editComposerJson($this->directory, ['repositories' => [$disabled]], addBlog(...)); @@ -169,8 +186,7 @@ function addBlog(ComposerJson $composerJson): void 'private' => ['type' => 'composer', 'url' => 'https://repo.example.com'], 'modules' => ['type' => 'vcs', 'url' => 'x'], 'modules-2' => PATH_REPOSITORY, - 'packagist' => PACKAGIST_EXCLUDING_APP, - 'packagist.org' => false, + 'packagist.org' => PACKAGIST_EXCLUDING_APP, ]); }); @@ -183,7 +199,7 @@ function addBlog(ComposerJson $composerJson): void it('ignores repository entries that are not objects', function () { $json = editComposerJson($this->directory, ['repositories' => ['modules/*', ['type' => 'path']]], addBlog(...)); - expect($json['repositories'])->toBe(['modules/*', ['type' => 'path'], PATH_REPOSITORY, PACKAGIST_EXCLUDING_APP, ['packagist.org' => false]]); + expect($json['repositories'])->toBe(['modules/*', ['type' => 'path'], PATH_REPOSITORY, PACKAGIST_EXCLUDING_APP]); }); it('keeps the formatting: empty objects, indentation, unicode and slashes', function () { @@ -218,9 +234,6 @@ function addBlog(ComposerJson $composerJson): void "exclude": [ "app/*" ] - }, - { - "packagist.org": false } ], "autoload-dev": { @@ -254,6 +267,16 @@ function addBlog(ComposerJson $composerJson): void expect(file_get_contents($this->directory.'/composer.json'))->toBe($original); }); +it('keeps the file mode', function () { + file_put_contents($path = $this->directory.'/composer.json', '{}'); + chmod($path, 0640); + + ComposerJson::read($path)->addProvider('A')->save(); + clearstatcache(); + + expect(fileperms($path) & 0777)->toBe(0640); +})->skipOnWindows(); + it('replaces the file atomically', function () { editComposerJson($this->directory, ['name' => 'demo/app'], addBlog(...)); @@ -288,6 +311,17 @@ function addBlog(ComposerJson $composerJson): void ]); }); +it('keeps a module required for development', function () { + $json = editComposerJson($this->directory, ['require-dev' => ['app/blog' => '*@dev']], addBlog(...)); + + expect($json)->not->toHaveKey('require') + ->and($json['require-dev'])->toBe(['app/blog' => '*@dev']); + + $json = editComposerJson($this->directory, $json, fn (ComposerJson $composerJson) => $composerJson->removeRequire('app/blog')); + + expect($json['require-dev'])->toBe([]); +}); + it('removes nothing when there is nothing to remove', function () { $json = editComposerJson($this->directory, ['name' => 'demo/app'], function (ComposerJson $composerJson): void { $composerJson->removeRequire('app/blog')->removeAutoloadDev(['Modules\\Blog\\Tests\\']); @@ -321,6 +355,7 @@ function addBlog(ComposerJson $composerJson): void ->and($composerJson->requires('app/blog'))->toBeTrue() ->and($composerJson->requires('vendor/pkg.name'))->toBeTrue() ->and($composerJson->requires('app/shop'))->toBeFalse() + ->and($composerJson->requires('php'))->toBeFalse() ->and($composerJson->toArray()['require'])->toBe(['app/blog' => '*', 'vendor/pkg.name' => '^1']); }); @@ -340,6 +375,10 @@ function addBlog(ComposerJson $composerJson): void 'only the vendor' => [[['type' => 'composer', 'url' => 'https://repo.packagist.org', 'only' => ['app/*']], ['packagist.org' => false]], false], 'disabled' => [[['packagist.org' => false]], true], 'disabled by name' => [['packagist.org' => false], true], + 'disabled, then redefined' => [[['packagist.org' => false], ['type' => 'composer', 'url' => 'https://repo.packagist.org']], false], + 'mirror excluding the vendor' => [['packagist.org' => ['type' => 'composer', 'url' => 'https://mirror.example.com', 'exclude' => ['app/*']]], true], + 'mirror' => [['packagist' => ['type' => 'composer', 'url' => 'https://mirror.example.com']], false], + 'unnamed mirror' => [[['type' => 'composer', 'url' => 'https://mirror.example.com', 'exclude' => ['app/*']]], false], ]); it('fails on files it can not edit', function (?string $contents, string $message) { diff --git a/tests/Unit/Scaffold/InstalledPackagesTest.php b/tests/Unit/Scaffold/InstalledPackagesTest.php index 9169c9e..5191d6a 100644 --- a/tests/Unit/Scaffold/InstalledPackagesTest.php +++ b/tests/Unit/Scaffold/InstalledPackagesTest.php @@ -48,6 +48,8 @@ expect($installed->isCurrent('app/blog', $composerJson))->toBe($current); })->with([ 'same' => [['name' => 'app/blog', 'require' => ['php' => '^8.3'], 'extra' => INSTALLED_BLOG['extra'], 'autoload' => INSTALLED_BLOG['autoload'], 'description' => 'changed'], true], + // Composer lowercases the names of the installed requirements. + 'name in another case' => [['require' => ['PHP' => '^8.3'], 'extra' => INSTALLED_BLOG['extra'], 'autoload' => INSTALLED_BLOG['autoload']], true], 'new provider' => [['require' => ['php' => '^8.3'], 'extra' => ['laravel' => ['providers' => ['A', 'B']]], 'autoload' => INSTALLED_BLOG['autoload']], false], 'new namespace' => [['require' => ['php' => '^8.3'], 'extra' => INSTALLED_BLOG['extra'], 'autoload' => ['psr-4' => ['Modules\\Blog\\' => 'src/', 'X\\' => 'x/']]], false], 'new dependency' => [['require' => ['php' => '^8.3', 'acme/lib' => '^1'], 'extra' => INSTALLED_BLOG['extra'], 'autoload' => INSTALLED_BLOG['autoload']], false], From 990e556fcc093f708d100c7e9d5d9e3d98fd1b6c Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 09:56:56 +0300 Subject: [PATCH 16/38] Apply the generators config to nested generators Generators called by another one (make:model -a) use the namespaces of the generators config, relative to the root namespace of each generator. Controllers import their form requests from the module, a fully qualified factory name stays in database/factories, component views are named relative to the components namespace and factories find module models outside Models. --- README.md | 14 +++- src/Generators/ComponentMakeCommand.php | 35 +++++++++- src/Generators/ControllerMakeCommand.php | 52 +++++++++++++++ src/Generators/FactoryMakeCommand.php | 17 +++++ src/Generators/ModelMakeCommand.php | 8 +-- src/Generators/ModuleOption.php | 33 ++++++--- src/Generators/NamespacesViews.php | 2 + src/Registrars/ConsoleRegistrar.php | 10 ++- tests/Feature/Discovery/FactoriesTest.php | 10 +++ tests/Generators/GeneratorsTest.php | 81 +++++++++++++++++++++++ 10 files changed, 240 insertions(+), 22 deletions(-) create mode 100644 src/Generators/ControllerMakeCommand.php diff --git a/README.md b/README.md index 47ede31..a084479 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,9 @@ Some generators are adjusted to the module: Nested commands run in the same module: `make:model Post --module=blog -mfs` creates the model, the migration, the factory and the seeder in `blog`. +`--model` and `--parent` name models of the module: `--model=Post` is `Modules\Blog\Models\Post`. As in the +application, they can not reference a model of another namespace. + ### Namespaces To use another layout, map a command to a namespace of the module in `config/modules.php`: @@ -277,8 +280,15 @@ To use another layout, map a command to a namespace of the module in `config/mod ``` `make:controller PostController --module=blog` then creates `Modules\Blog\UI\API\Controllers\PostController`. -A fully qualified name is used as it is. The `make:command` namespace is also where -[module commands](#commands) are discovered. +Nested commands use the config too: `make:model Post -a --module=blog` puts the controller and the form +requests there, and the controller imports the requests from `UI\API\Requests`. + +- A namespace is relative to where the generator puts classes in a module: `Modules\Blog\Tests` for + `make:test`, `Modules\Blog\Database\Factories` for `make:factory`, `Modules\Blog\Database\Seeders` + for `make:seeder` and `Modules\Blog` for the others. +- A name that starts with the module namespace is used as it is: `make:action "Modules\Blog\Domain\Publish"`. +- The `make:command` namespace is also where [module commands](#commands) are discovered, and the + `make:component` namespace is where `` components are looked up. The stubs of the generators are the Laravel ones: customize them with `php artisan stub:publish`. diff --git a/src/Generators/ComponentMakeCommand.php b/src/Generators/ComponentMakeCommand.php index 9038e1f..2395fed 100644 --- a/src/Generators/ComponentMakeCommand.php +++ b/src/Generators/ComponentMakeCommand.php @@ -5,11 +5,44 @@ namespace Laraneat\Modules\Generators; use Illuminate\Foundation\Console\ComponentMakeCommand as BaseComponentMakeCommand; +use Illuminate\Support\Facades\Config; +use Override; /** + * The view of a component is named after the class relative to the components namespace, + * so "Modules\Blog\View\Components\Forms\Input" renders "blog::components.forms.input". + * * @internal */ final class ComponentMakeCommand extends BaseComponentMakeCommand { - use NamespacesViews; + use NamespacesViews { + getView as private namespacedView; + } + + #[Override] + protected function getView() + { + $module = $this->currentModule(); + $name = $this->argument('name'); + + if ($module === null || ! is_string($name)) { + return $this->namespacedView(); + } + + $components = $module->namespace.'\\'.trim(Config::string('modules.generators.make:component', 'View\\Components'), '\\').'\\'; + $class = str_replace('/', '\\', ltrim($name, '\\/')); + + if (! str_starts_with($class, $components)) { + return $this->namespacedView(); + } + + $this->input->setArgument('name', substr($class, strlen($components))); + + try { + return $this->namespacedView(); + } finally { + $this->input->setArgument('name', $name); + } + } } diff --git a/src/Generators/ControllerMakeCommand.php b/src/Generators/ControllerMakeCommand.php new file mode 100644 index 0000000..36a39db --- /dev/null +++ b/src/Generators/ControllerMakeCommand.php @@ -0,0 +1,52 @@ + $replace + * @param string $modelClass + * @return array + */ + #[Override] + protected function buildFormRequestReplacements(array $replace, $modelClass) + { + $replace = parent::buildFormRequestReplacements($replace, $modelClass); + $module = $this->currentModule(); + + if ($module === null) { + return $replace; + } + + $namespace = $module->namespace.'\\'.trim(Config::string('modules.generators.make:request', 'Http\\Requests'), '\\').'\\'; + + foreach (self::REQUEST_REPLACEMENTS as $key) { + if (is_string($replace[$key] ?? null)) { + $replace[$key] = str_replace('App\\Http\\Requests\\', $namespace, $replace[$key]); + } + } + + return $replace; + } +} diff --git a/src/Generators/FactoryMakeCommand.php b/src/Generators/FactoryMakeCommand.php index d822030..3f72f6e 100644 --- a/src/Generators/FactoryMakeCommand.php +++ b/src/Generators/FactoryMakeCommand.php @@ -16,6 +16,23 @@ final class FactoryMakeCommand extends BaseFactoryMakeCommand { use ResolvesModule; + /** + * "Modules\Blog\Database\Factories\PostFactory" is "PostFactory" of the module, not + * a factory in "database/factories/Database/Factories". + */ + #[Override] + protected function qualifyClass($name) + { + $module = $this->currentModule(); + $name = str_replace('/', '\\', ltrim($name, '\\/')); + + if ($module !== null && str_starts_with($name, $factories = $module->namespace.'\\Database\\Factories\\')) { + $name = $module->namespace.'\\'.substr($name, strlen($factories)); + } + + return parent::qualifyClass($name); + } + #[Override] protected function buildClass($name) { diff --git a/src/Generators/ModelMakeCommand.php b/src/Generators/ModelMakeCommand.php index 2000406..0bf26ac 100644 --- a/src/Generators/ModelMakeCommand.php +++ b/src/Generators/ModelMakeCommand.php @@ -6,7 +6,6 @@ use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Foundation\Console\ModelMakeCommand as BaseModelMakeCommand; -use Illuminate\Support\Str; use Override; /** @@ -22,16 +21,15 @@ final class ModelMakeCommand extends BaseModelMakeCommand #[Override] protected function createFactory() { - $module = $this->currentModule(); - - if ($module === null) { + if ($this->currentModule() === null) { parent::createFactory(); return; } $this->call('make:factory', [ - 'name' => Str::after($this->factoryClass(), $module->namespace.'\\Database\\Factories\\'), + // Fully qualified, so the "generators" config does not move it away from the factory resolver. + 'name' => $this->factoryClass(), '--model' => $this->qualifyClass($this->getNameInput()), ]); } diff --git a/src/Generators/ModuleOption.php b/src/Generators/ModuleOption.php index 0e6ccbb..c6d393a 100644 --- a/src/Generators/ModuleOption.php +++ b/src/Generators/ModuleOption.php @@ -20,6 +20,7 @@ use Illuminate\Foundation\Console\ProviderMakeCommand as BaseProviderMakeCommand; use Illuminate\Foundation\Console\TestMakeCommand as BaseTestMakeCommand; use Illuminate\Foundation\Console\ViewMakeCommand as BaseViewMakeCommand; +use Illuminate\Routing\Console\ControllerMakeCommand as BaseControllerMakeCommand; use Illuminate\Support\Facades\Config; use Laraneat\Modules\Module; use Laraneat\Modules\ModuleRepository; @@ -38,6 +39,7 @@ final class ModuleOption { private const array GENERATORS = [ BaseComponentMakeCommand::class => ComponentMakeCommand::class, + BaseControllerMakeCommand::class => ControllerMakeCommand::class, BaseFactoryMakeCommand::class => FactoryMakeCommand::class, BaseMailMakeCommand::class => MailMakeCommand::class, BaseModelMakeCommand::class => ModelMakeCommand::class, @@ -78,13 +80,18 @@ public static function addTo(Command $command): void $command->setCode(Closure::bind(function (InputInterface $input, OutputInterface $output): int { /** @var Command $this */ $name = $input->getOption('module'); + $context = $this->getLaravel()->make(ModuleContext::class); + // A generator called by another one (make:model Post -a) runs in the module of the caller. if (! is_string($name)) { + if (($module = $context->module()) !== null) { + ModuleOption::qualifyName($this, $input, $module); + } + return $this->execute($input, $output); } - $laravel = $this->getLaravel(); - $module = $laravel->make(ModuleRepository::class)->find($name); + $module = $this->getLaravel()->make(ModuleRepository::class)->find($name); if ($module === null) { $this->components->error("Module [{$name}] not found."); @@ -92,30 +99,34 @@ public static function addTo(Command $command): void return Command::FAILURE; } - ModuleOption::qualifyName($this, $input, $module, Config::array('modules.generators', [])); + return $context->run($module, function () use ($input, $output, $module): int { + ModuleOption::qualifyName($this, $input, $module); - return $laravel->make(ModuleContext::class)->run($module, fn (): int => $this->execute($input, $output)); + return $this->execute($input, $output); + }); }, $command, $command::class)); } /** * Apply the "generators" config: "make:controller Post" becomes "Modules\Blog\\Post". - * A fully qualified name is left as is by the generator. - * - * @param array $generators + * The namespace is relative to the root namespace of the generator in the module, which is + * "Modules\Blog\Tests" for make:test and "Modules\Blog\Database\Seeders" for make:seeder. + * A name in the module namespace is left as it is. */ - public static function qualifyName(Command $command, InputInterface $input, Module $module, array $generators): void + public static function qualifyName(Command $command, InputInterface $input, Module $module): void { - $namespace = $generators[$command->getName()] ?? null; + $namespace = Config::array('modules.generators', [])[$command->getName()] ?? null; - if (! is_string($namespace) || ! $input->hasArgument('name') || ! is_string($class = $input->getArgument('name'))) { + if (! $command instanceof GeneratorCommand || ! is_string($namespace) || ! $input->hasArgument('name') || ! is_string($class = $input->getArgument('name'))) { return; } $class = trim(str_replace('/', '\\', $class), '\\'); if ($class !== '' && ! str_starts_with($class, $module->namespace.'\\')) { - $input->setArgument('name', $module->namespace.'\\'.trim($namespace, '\\').'\\'.$class); + $root = (fn (): string => $this->rootNamespace())->call($command); + + $input->setArgument('name', rtrim($root, '\\').'\\'.trim($namespace, '\\').'\\'.$class); } } } diff --git a/src/Generators/NamespacesViews.php b/src/Generators/NamespacesViews.php index f01292a..ab55664 100644 --- a/src/Generators/NamespacesViews.php +++ b/src/Generators/NamespacesViews.php @@ -46,6 +46,8 @@ protected function buildClass($name) /** * Not marked as an override: make:notification has getView() only since Laravel 13.33. + * + * @return string */ protected function getView() { diff --git a/src/Registrars/ConsoleRegistrar.php b/src/Registrars/ConsoleRegistrar.php index eeeb04b..c467eac 100644 --- a/src/Registrars/ConsoleRegistrar.php +++ b/src/Registrars/ConsoleRegistrar.php @@ -91,9 +91,13 @@ private function registerFactoryResolvers(): void $name = Str::replaceLast('Factory', '', Str::replaceFirst($factories, '', $factory::class)); - return class_exists($root.'Models\\'.$name) - ? $root.'Models\\'.$name - : $root.Str::replaceLast('Factory', '', class_basename($factory)); + foreach ([$root.'Models\\'.$name, $root.$name] as $model) { + if (class_exists($model)) { + return $model; + } + } + + return $root.Str::replaceLast('Factory', '', class_basename($factory)); }); } diff --git a/tests/Feature/Discovery/FactoriesTest.php b/tests/Feature/Discovery/FactoriesTest.php index d3c52a6..2c24da2 100644 --- a/tests/Feature/Discovery/FactoriesTest.php +++ b/tests/Feature/Discovery/FactoriesTest.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\Factory; use Modules\Blog\Database\Factories\PostFactory; use Modules\Blog\Models\Post; +use Modules\ShopOrder\Database\Factories\Entities\OrderFactory; use Modules\ShopOrder\Database\Factories\InvoiceFactory; it('pairs module models with the factories of the module', function (string $model, string $factory) { @@ -46,6 +47,15 @@ public function definition(): array expect((new InvoiceFactory)->modelName())->toBe('Modules\\ShopOrder\\Invoice'); }); +it('pairs a factory with a module model outside Models', function () { + $this->files([ + 'modules/shop-order/src/Entities/Order.php' => ' 'modelName())->toBe('Modules\\ShopOrder\\Entities\\Order'); +}); + it('keeps the default behavior for application factories', function () { eval('namespace Database\\Factories; class WidgetFactory extends \\Illuminate\\Database\\Eloquent\\Factories\\Factory { public function definition(): array { return []; } }'); diff --git a/tests/Generators/GeneratorsTest.php b/tests/Generators/GeneratorsTest.php index b183d85..9554c12 100644 --- a/tests/Generators/GeneratorsTest.php +++ b/tests/Generators/GeneratorsTest.php @@ -75,6 +75,87 @@ 'without --module' => ['make:action PublishPost', 'app/Actions/PublishPost.php'], ]); +it('applies the "generators" config to the generators a generator calls', function () { + $this->files(['config/modules.php' => ' ['.implode(', ', [ + '"make:model" => "Domain\\\\Models"', + '"make:controller" => "UI\\\\API\\\\Controllers"', + '"make:request" => "UI\\\\API\\\\Requests"', + '"make:policy" => "Domain\\\\Policies"', + ]).']];']); + $this->reboot(); + + $files = $this->generate('make:model Book -a --module=blog'); + + expect(array_keys($files))->toEqualCanonicalizing([ + 'modules/blog/src/Domain/Models/Book.php', + 'modules/blog/src/Domain/Policies/BookPolicy.php', + 'modules/blog/src/UI/API/Controllers/BookController.php', + 'modules/blog/src/UI/API/Requests/StoreBookRequest.php', + 'modules/blog/src/UI/API/Requests/UpdateBookRequest.php', + 'modules/blog/database/factories/Domain/Models/BookFactory.php', + 'modules/blog/database/migrations/{date}_create_books_table.php', + 'modules/blog/database/seeders/BookSeeder.php', + ]) + ->and($files['modules/blog/src/Domain/Models/Book.php'])->toContain('HasFactory<\\Modules\\Blog\\Database\\Factories\\Domain\\Models\\BookFactory>') + ->and($files['modules/blog/src/UI/API/Controllers/BookController.php'])->toContain( + 'namespace Modules\\Blog\\UI\\API\\Controllers;', + 'use Modules\\Blog\\Domain\\Models\\Book;', + "use Modules\\Blog\\UI\\API\\Requests\\StoreBookRequest;\nuse Modules\\Blog\\UI\\API\\Requests\\UpdateBookRequest;", + ) + ->and($files['modules/blog/database/factories/Domain/Models/BookFactory.php'])->toContain( + 'namespace Modules\\Blog\\Database\\Factories\\Domain\\Models;', + 'use Modules\\Blog\\Domain\\Models\\Book;', + ); +}); + +it('imports the form requests of a controller from the module', function () { + $files = $this->generate('make:controller PostController --model=Post --requests --module=blog'); + + expect($files['modules/blog/src/Http/Controllers/PostController.php']) + ->toContain("use Modules\\Blog\\Http\\Requests\\StorePostRequest;\nuse Modules\\Blog\\Http\\Requests\\UpdatePostRequest;") + ->not->toContain('App\\') + ->and($files)->toHaveKeys(['modules/blog/src/Http/Requests/StorePostRequest.php', 'modules/blog/src/Http/Requests/UpdatePostRequest.php']); +}); + +it('maps namespaces relative to the root namespace of the generator', function (string $command, array $expected) { + $this->files(['config/modules.php' => ' ['.implode(', ', [ + '"make:test" => "Feature\\\\Api"', + '"make:seeder" => "Demo"', + '"make:factory" => "Testing"', + '"make:component" => "UI\\\\Components"', + ]).']];']); + $this->reboot(); + + $files = $this->generate($command); + + expect(array_keys($files))->toEqualCanonicalizing(array_keys($expected)); + + foreach ($expected as $path => $contains) { + expect($files[$path])->toContain(...$contains); + } +})->with([ + 'test' => ['make:test PostTest --phpunit --module=blog', [ + 'modules/blog/tests/Feature/Api/PostTest.php' => ['namespace Modules\\Blog\\Tests\\Feature\\Api;'], + ]], + 'seeder' => ['make:seeder PostSeeder --module=blog', [ + 'modules/blog/database/seeders/Demo/PostSeeder.php' => ['namespace Modules\\Blog\\Database\\Seeders\\Demo;'], + ]], + 'factory' => ['make:factory PostFactory --module=blog', [ + 'modules/blog/database/factories/Testing/PostFactory.php' => ['namespace Modules\\Blog\\Database\\Factories\\Testing;'], + ]], + 'component' => ['make:component Forms/Input --module=blog', [ + 'modules/blog/src/UI/Components/Forms/Input.php' => ['namespace Modules\\Blog\\UI\\Components\\Forms;', "view('blog::components.forms.input')"], + 'modules/blog/resources/views/components/forms/input.blade.php' => ['
'], + ]], +]); + +it('creates a factory with a fully qualified name in the module', function () { + $files = $this->generate('make:factory "Modules\\\\Blog\\\\Database\\\\Factories\\\\Admin\\\\TagFactory" --module=blog'); + + expect(array_keys($files))->toBe(['modules/blog/database/factories/Admin/TagFactory.php']) + ->and($files['modules/blog/database/factories/Admin/TagFactory.php'])->toContain('namespace Modules\\Blog\\Database\\Factories\\Admin;'); +}); + it('generates into the application without --module', function (string $command, array $paths) { expect(array_keys($this->generate($command)))->toEqualCanonicalizing($paths); })->with([ From 3e7e9045b560b0b08d55362b13d631f1ac2931a1 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 09:59:39 +0300 Subject: [PATCH 17/38] Document the review findings Route loading order, the provider register order, config merging, console-only migrations and commands, seeder discovery, the cache in development and production, Packagist mirrors and the upgrade steps that were missing or wrong. --- CHANGELOG.md | 15 ++++++++---- README.md | 45 ++++++++++++++++++++++++++++-------- UPGRADE.md | 65 +++++++++++++++++++++++++++++++++++++++------------- 3 files changed, 94 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eea674a..bf925e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,10 @@ Version 3 is a rewrite. See [UPGRADE.md](UPGRADE.md) for the upgrade from 2.x. - `Modules::seeders()` returns the seeders of all modules, in the order of their `_N` suffix. - Module templates in `stubs/module/` with `{{ variable|filter }}` placeholders in paths and contents. - `module:doctor` checks the modules and how they are installed. -- `module:make`, `module:sync` and `module:delete` run Composer themselves; `--no-update` prints the command instead. -- Module vendors are excluded from Packagist, so a missing module is never replaced by a public package. +- `--no-update` for `module:make`, `module:sync` and `module:delete`: edit `composer.json` and print the + Composer command instead of running it. +- `module:sync` adds the `autoload-dev` of the module tests and updates only the modules that changed. +- A Packagist mirror under the `packagist.org` repository key gets the exclusion of the module vendor too. - The module manifest is cached by `php artisan optimize` and cleared by `optimize:clear`; `MODULES_CACHE` changes the cache path. - The modules path is added to `octane.watch`. @@ -30,9 +32,12 @@ Version 3 is a rewrite. See [UPGRADE.md](UPGRADE.md) for the upgrade from 2.x. - The facade is `Laraneat\Modules\Facades\Modules`; modules are identified by their directory name. - `Laraneat\Modules\Module` is a read-only value object with public properties. - The views and translations namespace of a module is its directory name. -- `module:delete` deletes one module, uninstalls it with Composer before deleting its files, and never deletes - a symlinked module directory. -- Without the cache file, the manifest is built once per process, so it is never stale in development. +- `module:delete` deletes one module, found by its directory name, asks for confirmation and needs `--force` + in non-interactive mode. +- The manifest cache is written only by `php artisan optimize` and `module:cache`, not automatically in production. +- `Laraneat\Modules\ModulesRepository` is renamed to `Laraneat\Modules\ModuleRepository`. +- The `composer.vendor` config key is renamed to `vendor`. +- Migrations are registered only in the console. ### Removed diff --git a/README.md b/README.md index a084479..143c54c 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,9 @@ A module is a directory in `modules/` with a `composer.json`: - The directory name (`blog`) is the module name. It is also the namespace of the module views, translations and Blade components: `view('blog::index')`, `__('blog::messages.welcome')`, ``. - The first `autoload.psr-4` entry is the root namespace of the module. +- Every module must have a valid `composer.json` with a unique package name and namespace. An invalid + module is an error while the application boots, for HTTP requests too: `module:doctor` and the tests + catch it before a deploy. - The module is required by the application like any other package: ```json @@ -125,14 +128,17 @@ The name is normalized to kebab case (`ShopOrder` becomes `shop-order`, namespac The built-in template has a `composer.json` and empty convention directories. Your own templates live in `stubs/module/`: `--preset=api` uses `stubs/module/api`, and `stubs/module/default` replaces the -built-in template. Start from a copy of the built-in one: +built-in template. Publish the built-in template to `stubs/module/default`, and copy it to +`stubs/module/` to start a named preset: ```bash php artisan vendor:publish --tag=modules-stubs +cp -R stubs/module/default stubs/module/api ``` - Files ending in `.stub` are rendered and lose the extension. Other files are copied as they are. -- Placeholders work in file contents and in paths: `{{ variable }}` or `{{ variable|filter|filter }}`. +- Placeholders work in the contents of `.stub` files and in all paths: `{{ variable }}` or + `{{ variable|filter|filter }}`. - Variables: | Variable | Example value | @@ -177,12 +183,18 @@ The package loads everything it finds in a module. A missing directory is skippe `src/` is the directory of the root namespace of the module: `View/Components` and `Console/Commands` are looked up there. +A module config file can extend any key, `config/app.php` included. The merge is shallow: the top-level keys +of the application config replace those of the module. + ### Factories `Modules\Blog\Models\Post::factory()` uses `Modules\Blog\Database\Factories\PostFactory`, and the factory knows its model, the way Laravel pairs `App\Models\Post` with `Database\Factories\PostFactory`. Classes outside the modules keep the Laravel behavior. +The resolver is global (`Factory::guessFactoryNamesUsing()`): a resolver that the application sets itself +replaces it. + The factory resolver is registered only in the console (tests, seeders, Tinker), because factories are not used while serving requests. If you create models with factories during HTTP requests, point the model at its factory: @@ -201,7 +213,8 @@ final class Post extends Model Classes in the `make:command` namespace of a module (`src/Console/Commands`, including subdirectories) are registered as Artisan commands. Abstract classes and other classes are skipped. Commands with the -`#[AsCommand]` attribute are loaded lazily. +`#[AsCommand]` attribute are loaded lazily. Like migrations, module commands are registered only in the +console, so `Artisan::call()` can not run them during an HTTP request. ### Policies and events @@ -226,6 +239,9 @@ Route groups are defined in `config/modules.php`: - Files of a directory are loaded before its subdirectories, in alphabetical order. - Groups apply to all modules. A module with its own layout can load its routes in its service provider. - Nothing is loaded when the routes are cached: `php artisan route:cache` includes the module routes. +- Route files are loaded while the package boots, like the routes of other packages: before the providers + of the application boot. `Route::pattern()` and route macros from `AppServiceProvider::boot()` do not reach + them. Define those in the `register()` method of a provider, or use `->where()` in the route files. ```php // modules/blog/routes/api/posts.php @@ -305,9 +321,10 @@ php artisan module:sync The provider is registered in `extra.laravel.providers` of the module `composer.json`; `module:sync` installs the changed module, so that Laravel package discovery picks it up. -Module providers are loaded by package discovery, in the order of the package names. The module config -is merged while the Laraneat provider registers, which may happen after the `register()` of a module -provider. Read config in `boot()`, or inside the closures of your bindings, not directly in `register()`. +Module providers are loaded by package discovery, in the order of the package names. The module config, +views and translations are registered while the Laraneat provider registers, which may happen after the +`register()` of a module provider. In `register()`, do not read config or use the `Modules` facade directly: +do it in `boot()` or inside the closures of your bindings. ## Seeders @@ -327,7 +344,9 @@ final class DatabaseSeeder extends Seeder - Without arguments it returns the seeders of `database/seeders`. Pass subdirectories to add them: `Modules::seeders('', 'Demo')` returns `database/seeders` and `database/seeders/Demo`, - `Modules::seeders('Demo')` only `database/seeders/Demo`. Subdirectories are not searched recursively. + `Modules::seeders('Demo')` only `database/seeders/Demo`. Only direct subdirectories are read: + `Modules::seeders('Demo/Extra')` returns nothing. +- Class names come from the file paths (PSR-4): a seeder whose namespace does not match its path is not found. - Seeders run in the order of their integer `_N` class name suffix (`PermissionsSeeder_1` before `UsersSeeder_2`); seeders without a suffix run last. Equal suffixes run in the order of the class names. - Abstract classes and classes that do not extend `Seeder` are skipped. @@ -362,12 +381,14 @@ pest()->extend(Tests\TestCase::class)->in('Feature', '../modules/*/tests/Feature |---------------------------------|-------------| | `module:make ` | Create a module. `--preset=` picks the template, `--no-update` skips `composer update`. | | `module:sync` | Add every module to the `composer.json` of the application and run `composer update` for the modules that changed. `--no-update` only prints the command. | -| `module:delete ` | Run `composer remove` for the module, then delete its directory. Asks for confirmation; `--force` skips it. `--no-update` only edits `composer.json`. A symlinked module directory is never deleted. | +| `module:delete ` | Run `composer remove` for the module, then delete its directory. Asks for confirmation; `--force` skips it. With `--no-update`, it edits `composer.json` instead of running Composer, deletes the directory and prints the `composer update` command to run. A symlinked module directory is never deleted. | | `module:doctor` | Check the modules and how they are installed. Read-only; fails when it finds errors. | | `module:list` | List the modules. With `-v`, also what is loaded from each of them. | | `module:cache` | Cache the module manifest. Part of `php artisan optimize`. | | `module:clear` | Remove the module manifest cache. Part of `php artisan optimize:clear`. | +Commands and `--module` take the module directory name (`blog`), not the package name (`app/blog`). + `module:doctor` checks: - that every module is required, excluded from Packagist and installed, and that the installed package @@ -413,8 +434,12 @@ provider only reads the manifest, it never scans the modules while it boots. - `php artisan optimize` (or `module:cache`) writes the manifest to `bootstrap/cache/modules.php`, next to the config and route caches. A cached application loads it with one `require`. Set `MODULES_CACHE` to - store it elsewhere, like `APP_CONFIG_CACHE`. -- Without the cache file, the manifest is built once per process. There is no stale cache in development. + store it elsewhere, like `APP_CONFIG_CACHE`. It must be a real environment variable: `.env` is not read + when the config is cached. +- Without the cache file, the manifest is built once per process, so it is never stale. The cache is written + only by `optimize` and `module:cache`: a deploy that skips them scans the modules in every process. +- The cached manifest lists the files it loads. After `php artisan optimize` on a development machine, new + route, config, seeder and command files are ignored until `php artisan optimize:clear`. - `module:make`, `module:sync` and `module:delete` rebuild the cache file if it exists. - The config and route caches include the module config and routes, so the package skips them when they are cached. diff --git a/UPGRADE.md b/UPGRADE.md index 069a80d..d08855d 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -15,8 +15,9 @@ every step, and run the checks at the end. - Save the routes of the application to compare them later: ```bash - php artisan route:list --json > /tmp/routes-before.json - php artisan module:list + php artisan route:list --json \ + | php -r 'echo json_encode(json_decode(stream_get_contents(STDIN)), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), PHP_EOL;' \ + > /tmp/routes-before.json ``` ### 1. Update the package @@ -42,10 +43,13 @@ rm -f bootstrap/cache/laraneat-modules.php Copy the new config next to the old one and move your values into it: ```bash -mv config/modules.php config/modules.v2.php +mv config/modules.php config/modules.php.v2 cp vendor/laraneat/modules/config/modules.php config/modules.php ``` +Laravel loads only the `.php` files of `config/`, so the old file, which references removed classes, is not +loaded anymore. + | 2.x key | 3.0 | |----------------------------------|------------------------------------------------------------------------------| | `path`, `namespace` | the same keys | @@ -76,12 +80,13 @@ layout: One command has one namespace. 2.x had separate API and WEB controllers and requests: generate the other kind with a fully qualified name, for example -`make:controller "Modules\Blog\UI\WEB\Controllers\PostController" --module=blog`. +`make:controller "Modules\Blog\UI\WEB\Controllers\PostController" --module=blog`. The namespaces also apply +to the files that options such as `make:model -a` create. The `make:command` namespace is also where module commands are discovered, so it must match the directory of the existing commands. -Delete `config/modules.v2.php` when everything is moved: it references classes that do not exist anymore. +Delete `config/modules.php.v2` when everything is moved. ### 3. Remove the module service providers that only load resources @@ -106,8 +111,10 @@ What 3.0 loads, and how it differs from the 2.x providers: not publishable anymore. - **Views and translations**: the namespace is the module directory name (`blog::`). In 2.x it was the kebab case name of the module; rename the directory or the references if they differ. Views published into - `resources/views/modules/` do not override the module views anymore: move the changes into the module. -- **Migrations and commands**: loaded in the console, like in 2.x. + `resources/views/modules/` or `resources/views/vendor/` do not override the module views + anymore: move the changes into the module. Translation overrides in `lang/vendor/` still work. +- **Migrations and commands**: registered only in the console. 2.x registered migrations for HTTP requests + too: `Artisan::call('migrate')` during an HTTP request does not see the module migrations anymore. Search for leftovers: @@ -135,6 +142,10 @@ Two differences from `CanLoadRoutesFromDirectory`: prefix; now it gets `api/v1/admin`. Routes one directory deep keep their URIs. - The files of a directory are loaded before its subdirectories (2.x loaded subdirectories first), and groups are loaded for all modules in turn: first every `api` route, then every `web` route. +- Route files are loaded while the package boots, before the providers of the application boot. 2.x loaded + them after the module route service provider booted. `Route::pattern()` and route macros defined in + `AppServiceProvider::boot()` do not reach module routes anymore: define them in the `register()` method + of a provider, or use `->where()` in the route files. When two routes can match the same URL (`posts/{post}` and `posts/export`), make sure the specific one is still registered first. @@ -161,7 +172,12 @@ $this->call(Modules::seeders('', 'Deployment', 'Demo')); `runSeedersFromModules($directories)` always included `database/seeders`; with `Modules::seeders()`, `''` is `database/seeders` and you list it yourself. The order is the same: by the `_N` class name suffix, then by -class name. Abstract classes and classes that are not seeders are skipped now. +class name. Differences: + +- Abstract classes and classes that are not seeders are skipped. +- Only direct subdirectories of `database/seeders` are read: `Modules::seeders('Demo/Extra')` returns nothing. +- Class names come from the file paths, not from the namespace in the file: a seeder whose namespace does + not match its path is not found. If you overrode methods of the trait (`sortSeederClasses()`, `getSeederClassesFromModule()`), apply the same logic to the array that `Modules::seeders()` returns. @@ -347,7 +363,7 @@ trait WithJsonResponseHelpers Then replace the imports. The calls stay the same: ```bash -grep -rlZ "Laraneat\\\\Modules\\\\Support\\\\Concerns\\\\\(InteractsWithTestUser\|WithJsonResponseHelpers\)" app modules tests \ +grep -rl --null "Laraneat\\\\Modules\\\\Support\\\\Concerns\\\\\(InteractsWithTestUser\|WithJsonResponseHelpers\)" app modules tests \ | xargs -0 perl -pi -e 's/Laraneat\\Modules\\Support\\Concerns\\InteractsWithTestUser/Tests\\Concerns\\InteractsWithTestUser/g; s/Laraneat\\Modules\\Support\\Concerns\\WithJsonResponseHelpers/App\\Support\\Concerns\\WithJsonResponseHelpers/g' ``` @@ -360,7 +376,11 @@ Two changes in `InteractsWithTestUser`: ### 7. Update the code that uses the package API -- The facade is `Laraneat\Modules\Facades\Modules` (was `Laraneat\Modules\Support\Facades\Modules`). +- The facade is `Laraneat\Modules\Facades\Modules` (was `Laraneat\Modules\Support\Facades\Modules`). The + global `Modules` alias points to the new facade. +- `Laraneat\Modules\ModulesRepository` is `Laraneat\Modules\ModuleRepository`. The 2.x exceptions and the + classes of `Laraneat\Modules\Providers` are removed; the exceptions of 3.0 extend + `Laraneat\Modules\Exceptions\ModulesException`. - Modules are identified by their directory name, not by the package name. | 2.x | 3.0 | @@ -370,13 +390,19 @@ Two changes in `InteractsWithTestUser`: | `Modules::findOrFail('app/blog')` | `Modules::get('blog')` | | `Modules::has('app/blog')` | `Modules::find('blog') !== null` | | `Modules::count()` | `count(Modules::all())` | +| `Modules::filterByName('blog')`, `filterByNameOrFail()` | `Modules::find('blog')`, `Modules::get('blog')` | +| `Modules::getScanPaths()`, `addScanPath()` | the `path` config key: one directory of modules | +| `Modules::toArray()`, `$module->toArray()` | `Modules::all()`, the public properties of `Module` | | `Modules::delete()`, `syncWithComposer()` | `php artisan module:delete`, `module:sync` | | `Modules::buildModulesManifest()`, `pruneModulesManifest()` | `php artisan module:cache`, `module:clear` | | `$module->getName()`, `getPackageName()`, `getNamespace()`, `getPath()` | `$module->name`, `->package`, `->namespace`, `->path` | | `$module->subPath('src')`, `subNamespace('Models')` | `$module->path.'/src'`, `$module->namespace.'\\Models'` | +| `$module->getStudlyName()`, `getKebabName()`, `getSnakeName()` | `Str::studly($module->name)`, `$module->name`, `Str::snake(Str::camel($module->name))` | +| `$module->getProviders()`, `getAliases()` | `extra.laravel` of the module `composer.json` | ```bash -grep -rnE 'Laraneat\\Modules\\(Support|Enums|Module;)' app modules tests database routes config bootstrap +grep -rnE 'Laraneat\\Modules\\(Support|Enums|Exceptions|Providers|ModulesRepository|Module;)|Modules::' \ + app modules tests database routes config bootstrap ``` ### 8. Reinstall the modules and sync them @@ -409,6 +435,9 @@ The `module:make:*` commands are replaced by the Laravel generators with `--modu | `module:make:route`, `module:make:query-wizard` | removed: create the files by hand or with your own generator | | `module:stub:publish`, `custom_stubs` | `php artisan stub:publish`, which customizes the Laravel stubs | +`make:data` adds the `Data` suffix to names that do not end with it: `make:data CreatePostDTO` creates +`CreatePostDTOData`. Pass the full name without a suffix, or configure the suffix of laravel-data. + The generated code follows the Laravel stubs, not the Porto stubs of 2.x. If you relied on the 2.x stubs, put your versions in `stubs/` with `php artisan stub:publish`; they apply to the application and to the modules alike. @@ -424,16 +453,17 @@ Module migrations are registered with the Laravel migrator: | `module:migrate:rollback`, `:refresh`, `:reset`, `:status` | `migrate:rollback`, `migrate:refresh`, `migrate:reset`, `migrate:status` | Update scripts, CI jobs and deployment that call the old commands. `module:delete` now deletes one module -at a time, and needs `--force` in non-interactive mode. +at a time, takes the directory name (`blog`, not `app/blog`), and needs `--force` in non-interactive mode. ### 11. Replace the module templates `module:make --preset=plain|base|api --entity=...` is replaced by module templates: `module:make blog ---preset=` renders `stubs/module/`. Start from the built-in template and add the files your -modules begin with: +--preset=` renders `stubs/module/`. Start from the built-in template, which is published to +`stubs/module/default`, and add the files your modules begin with: ```bash php artisan vendor:publish --tag=modules-stubs +cp -R stubs/module/default stubs/module/api ``` The placeholders are described in the [README](README.md#module-templates). `--entity` is gone: use the @@ -446,6 +476,7 @@ module name with filters, for example `{{ name|studly }}`. that create factories while serving HTTP requests. - Remove the deployment steps that cleared `bootstrap/cache` to refresh the module cache. Run `php artisan optimize` when you deploy; it caches the modules together with the config and routes. + 2.x wrote the cache by itself in production, 3.0 does not: without `optimize`, every process scans the modules. - `octane.watch` does not need the modules path anymore; it is added automatically. ### Check the upgrade @@ -453,8 +484,10 @@ module name with filters, for example `{{ name|studly }}`. ```bash php artisan optimize:clear php artisan module:doctor -php artisan route:list --json > /tmp/routes-after.json -diff <(jq -S . /tmp/routes-before.json) <(jq -S . /tmp/routes-after.json) +php artisan route:list --json \ + | php -r 'echo json_encode(json_decode(stream_get_contents(STDIN)), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), PHP_EOL;' \ + > /tmp/routes-after.json +diff /tmp/routes-before.json /tmp/routes-after.json php artisan test php artisan optimize && php artisan optimize:clear ``` From 330160fe59012bb0b894cf9323390c552831c8d3 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 10:06:30 +0300 Subject: [PATCH 18/38] Test the form requests of an application controller --- tests/Feature/Commands/DoctorCommandTest.php | 2 +- tests/Generators/GeneratorsTest.php | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/Feature/Commands/DoctorCommandTest.php b/tests/Feature/Commands/DoctorCommandTest.php index c6ce89c..b88d945 100644 --- a/tests/Feature/Commands/DoctorCommandTest.php +++ b/tests/Feature/Commands/DoctorCommandTest.php @@ -112,7 +112,7 @@ function doctor(): array } expect($status)->toBe(0)->and($output)->toContain('No problems found.'); -})->skip(fn (): bool => PHP_OS_FAMILY === 'Windows' || posix_geteuid() === 0, 'Permissions are not enforced.'); +})->skip(fn () => DIRECTORY_SEPARATOR === '\\' || posix_getuid() === 0, 'File permissions are not enforced.'); it('accepts equivalent autoload paths and skips directories a module does not have', function () { // shop-order has no database/factories directory. diff --git a/tests/Generators/GeneratorsTest.php b/tests/Generators/GeneratorsTest.php index 9554c12..21048f8 100644 --- a/tests/Generators/GeneratorsTest.php +++ b/tests/Generators/GeneratorsTest.php @@ -117,6 +117,13 @@ ->and($files)->toHaveKeys(['modules/blog/src/Http/Requests/StorePostRequest.php', 'modules/blog/src/Http/Requests/UpdatePostRequest.php']); }); +it('keeps the form requests of an application controller', function () { + $files = $this->generate('make:controller PostController --model=Post --requests --no-interaction'); + + expect($files['app/Http/Controllers/PostController.php']) + ->toContain("use App\\Http\\Requests\\StorePostRequest;\nuse App\\Http\\Requests\\UpdatePostRequest;"); +}); + it('maps namespaces relative to the root namespace of the generator', function (string $command, array $expected) { $this->files(['config/modules.php' => ' ['.implode(', ', [ '"make:test" => "Feature\\\\Api"', From fe9cf29e11282a825c343340c0eed04d7a0e8314 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 10:13:56 +0300 Subject: [PATCH 19/38] Treat any repository named packagist.org as the Packagist slot Composer replaces the default repository with whatever is defined under that name, so a non-composer repository there disables Packagist. --- src/Scaffold/ComposerJson.php | 13 ++++++------- tests/Generators/GeneratorsTest.php | 8 ++++++++ tests/Unit/Scaffold/ComposerJsonTest.php | 4 ++++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/Scaffold/ComposerJson.php b/src/Scaffold/ComposerJson.php index d498ab4..90452ed 100644 --- a/src/Scaffold/ComposerJson.php +++ b/src/Scaffold/ComposerJson.php @@ -257,23 +257,22 @@ private function excludeFromPackagist(string $package): void /** * The repository Composer uses in place of the default Packagist repository: a composer repository * with a packagist.org URL, or one named "packagist.org" or "packagist" whatever its URL (a mirror). - * False when Packagist is disabled, null for the default repository. + * False when Packagist is disabled or replaced by another kind of repository, null for the default one. */ private function packagist(): stdClass|false|null { $disabled = false; foreach ($this->repositories() as $name => $repository) { + // A repository under this name takes the place of the default one, false disables it. if (is_string($name) && in_array($name, ['packagist', 'packagist.org'], true)) { - if ($repository === false) { - $disabled = true; - - continue; - } - if ($repository instanceof stdClass && ($repository->type ?? null) === 'composer') { return $repository; } + + $disabled = true; + + continue; } if (! $repository instanceof stdClass) { diff --git a/tests/Generators/GeneratorsTest.php b/tests/Generators/GeneratorsTest.php index 21048f8..ae3fd01 100644 --- a/tests/Generators/GeneratorsTest.php +++ b/tests/Generators/GeneratorsTest.php @@ -163,6 +163,14 @@ ->and($files['modules/blog/database/factories/Admin/TagFactory.php'])->toContain('namespace Modules\\Blog\\Database\\Factories\\Admin;'); }); +it('maps only the names of class generators', function () { + $this->files(['config/modules.php' => ' ["make:migration" => "Migrations"]];']); + $this->reboot(); + + expect(array_keys($this->generate('make:migration create_tags_table --module=blog'))) + ->toBe(['modules/blog/database/migrations/{date}_create_tags_table.php']); +}); + it('generates into the application without --module', function (string $command, array $paths) { expect(array_keys($this->generate($command)))->toEqualCanonicalizing($paths); })->with([ diff --git a/tests/Unit/Scaffold/ComposerJsonTest.php b/tests/Unit/Scaffold/ComposerJsonTest.php index 79c332c..537d7ac 100644 --- a/tests/Unit/Scaffold/ComposerJsonTest.php +++ b/tests/Unit/Scaffold/ComposerJsonTest.php @@ -379,6 +379,10 @@ function addBlog(ComposerJson $composerJson): void 'mirror excluding the vendor' => [['packagist.org' => ['type' => 'composer', 'url' => 'https://mirror.example.com', 'exclude' => ['app/*']]], true], 'mirror' => [['packagist' => ['type' => 'composer', 'url' => 'https://mirror.example.com']], false], 'unnamed mirror' => [[['type' => 'composer', 'url' => 'https://mirror.example.com', 'exclude' => ['app/*']]], false], + 'replaced by another kind of repository' => [['packagist.org' => ['type' => 'vcs', 'url' => 'https://example.com/repo.git']], true], + 'disabled by name, then redefined' => [['packagist.org' => false, 'main' => ['type' => 'composer', 'url' => 'https://repo.packagist.org']], false], + 'after entries that are not objects' => [['modules/*', PACKAGIST_EXCLUDING_APP], true], + 'not a composer repository' => [[['type' => 'vcs', 'url' => 'https://packagist.org/app/blog', 'exclude' => ['app/*']]], false], ]); it('fails on files it can not edit', function (?string $contents, string $message) { From 178577efc5cb5cba5ce3fb6b8a6ff2d38264a34c Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 10:25:20 +0300 Subject: [PATCH 20/38] Exclude the vendor from named Packagist mirrors and check copies by their source Composer 2.10 writes a mirror as a list entry named packagist and uses it next to the default repository, so both get the exclusion. module:doctor only takes an installed directory for a copy when Composer installed it from the module path. --- src/Commands/DoctorCommand.php | 4 +- src/Scaffold/ApplicationComposer.php | 5 +- src/Scaffold/ComposerJson.php | 78 +++++++++++++------- src/Scaffold/InstalledPackages.php | 10 +++ tests/Feature/Commands/DoctorCommandTest.php | 17 +++++ tests/Unit/Scaffold/ComposerJsonTest.php | 15 +++- 6 files changed, 101 insertions(+), 28 deletions(-) diff --git a/src/Commands/DoctorCommand.php b/src/Commands/DoctorCommand.php index 6097425..520a92c 100644 --- a/src/Commands/DoctorCommand.php +++ b/src/Commands/DoctorCommand.php @@ -114,8 +114,10 @@ private function checkModule(Module $module, array $manifest, ComposerJson $root if (! $installed->has($module->package)) { $problems[] = [true, "[{$module->package}] is not installed: run \"php artisan module:sync\"."]; } elseif (realpath($vendorPath = $installed->path($module->package)) !== realpath($module->path)) { + $source = $installed->pathSource($module->package); + // COMPOSER_MIRROR_PATH_REPOS=1 copies path packages, as Docker images often do. - $problems[] = is_dir($vendorPath) && ! is_link($vendorPath) + $problems[] = $source !== null && ! is_link($vendorPath) && realpath($composer->absolute($source)) === realpath($module->path) ? [false, "[{$composer->relative($vendorPath)}] is a copy, not a link to [{$composer->relative($module->path)}]: changes of the module need \"composer update\"."] : [true, "[{$composer->relative($vendorPath)}] is not a link to [{$composer->relative($module->path)}]."]; } elseif (! $installed->isCurrent($module->package, $composerJson->toArray())) { diff --git a/src/Scaffold/ApplicationComposer.php b/src/Scaffold/ApplicationComposer.php index 7beb1a6..ca890d6 100644 --- a/src/Scaffold/ApplicationComposer.php +++ b/src/Scaffold/ApplicationComposer.php @@ -82,7 +82,10 @@ public function relative(string $path): string return str_starts_with($path.'/', $this->basePath.'/') ? ltrim(substr($path, strlen($this->basePath)), '/') : $path; } - private function absolute(string $path): string + /** + * A path relative to the application made absolute. + */ + public function absolute(string $path): string { return preg_match('{^(/|[A-Za-z]:[/\\\\])}', $path) === 1 ? $path : $this->basePath.'/'.$path; } diff --git a/src/Scaffold/ComposerJson.php b/src/Scaffold/ComposerJson.php index 90452ed..59805b1 100644 --- a/src/Scaffold/ComposerJson.php +++ b/src/Scaffold/ComposerJson.php @@ -165,21 +165,19 @@ public function requires(string $package): bool } /** - * Whether Composer never looks the package up on Packagist. + * Whether Composer never looks the package up on Packagist or on a Packagist mirror. */ public function isExcludedFromPackagist(string $package): bool { - $packagist = $this->packagist(); - - if (! $packagist instanceof stdClass) { - return $packagist === false; + foreach ($this->namedMirrors() as $mirror) { + if (! $this->excludes($mirror, $package)) { + return false; + } } - if (is_array($packagist->only ?? null)) { - return ! $this->matches($package, $packagist->only); - } + $packagist = $this->packagist(); - return $this->matches($package, is_array($packagist->exclude ?? null) ? $packagist->exclude : []); + return $packagist instanceof stdClass ? $this->excludes($packagist, $package) : $packagist === false; } public function isDirty(): bool @@ -223,35 +221,65 @@ private function addPathRepository(string $url): void */ private function excludeFromPackagist(string $package): void { - $packagist = $this->packagist(); - - if ($packagist === false) { - return; - } - $pattern = strstr($package, '/', true).'/*'; + $repositories = $this->namedMirrors(); + $packagist = $this->packagist(); if ($packagist === null) { // Composer replaces the default Packagist repository with a repository of its URL. $this->addRepository('packagist.org', (object) ['type' => 'composer', 'url' => self::PACKAGIST, 'exclude' => [$pattern]]); - - return; + } elseif ($packagist !== false) { + $repositories[] = $packagist; } - // Composer does not accept "only" and "exclude" together. - if (is_array($packagist->only ?? null)) { - if ($this->matches($package, $packagist->only)) { - throw ComposerFailed::because("[{$this->path}] Packagist serves [{$package}] through \"only\": remove the pattern that matches it."); + foreach ($repositories as $repository) { + // Composer does not accept "only" and "exclude" together. + if (is_array($repository->only ?? null)) { + if ($this->matches($package, $repository->only)) { + throw ComposerFailed::because("[{$this->path}] a Packagist repository serves [{$package}] through \"only\": remove the pattern that matches it."); + } + + continue; + } + + $exclude = is_array($repository->exclude ?? null) ? $repository->exclude : []; + + if (! in_array($pattern, $exclude, true)) { + $repository->exclude = [...$exclude, $pattern]; } + } + } - return; + private function excludes(stdClass $repository, string $package): bool + { + if (is_array($repository->only ?? null)) { + return ! $this->matches($package, $repository->only); } - $exclude = is_array($packagist->exclude ?? null) ? $packagist->exclude : []; + return $this->matches($package, is_array($repository->exclude ?? null) ? $repository->exclude : []); + } + + /** + * Mirrors in the repository list with the "packagist.org" or "packagist" name, as Composer 2.10 + * writes them for "composer config repo.packagist composer ". Composer ignores the name: + * they are used next to the default Packagist repository, not in its place. + * + * @return list + */ + private function namedMirrors(): array + { + $mirrors = []; - if (! in_array($pattern, $exclude, true)) { - $packagist->exclude = [...$exclude, $pattern]; + foreach ($this->repositories() as $name => $repository) { + if (is_int($name) + && $repository instanceof stdClass + && ($repository->type ?? null) === 'composer' + && in_array($repository->name ?? null, ['packagist', 'packagist.org'], true)) { + $mirrors[] = $repository; + } } + + return $mirrors; } /** diff --git a/src/Scaffold/InstalledPackages.php b/src/Scaffold/InstalledPackages.php index 64fdc3a..c24fa36 100644 --- a/src/Scaffold/InstalledPackages.php +++ b/src/Scaffold/InstalledPackages.php @@ -60,6 +60,16 @@ public function isCurrent(string $package, array $composerJson): bool && ($installed['extra'] ?? []) == ($composerJson['extra'] ?? []); } + /** + * The directory of the path repository the package was installed from, as Composer wrote it. + */ + public function pathSource(string $package): ?string + { + $dist = $this->packages[$package]['dist'] ?? null; + + return is_array($dist) && ($dist['type'] ?? null) === 'path' && is_string($dist['url'] ?? null) ? $dist['url'] : null; + } + /** * The directory the package is installed in. */ diff --git a/tests/Feature/Commands/DoctorCommandTest.php b/tests/Feature/Commands/DoctorCommandTest.php index b88d945..2539b39 100644 --- a/tests/Feature/Commands/DoctorCommandTest.php +++ b/tests/Feature/Commands/DoctorCommandTest.php @@ -57,6 +57,23 @@ function doctor(): array ->and($output)->toContain('! [vendor/app/blog] is a copy, not a link to [modules/blog]: changes of the module need "composer update".'); }); +it('reports a package installed from another repository', function (array $dist) { + $this->installModules(); + unlink($this->path('vendor/app/blog')); + mkdir($this->path('vendor/app/blog')); + $installed = $this->readJson('vendor/composer/installed.json'); + $installed['packages'][0]['dist'] = $dist; + $this->files(['vendor/composer/installed.json' => json_encode($installed)]); + + [$status, $output] = doctor(); + + expect($status)->toBe(1) + ->and($output)->toContain('✗ [vendor/app/blog] is not a link to [modules/blog].'); +})->with([ + 'Packagist' => [['type' => 'zip', 'url' => 'https://api.github.com/repos/app/blog/zipball/1', 'reference' => '1']], + 'another path' => [['type' => 'path', 'url' => 'modules/shop-order', 'reference' => null]], +]); + it('reports outdated installed metadata', function () { $this->installModules(); $this->files(['modules/blog/composer.json' => json_encode([...$this->readJson('modules/blog/composer.json'), 'require' => ['php' => '^8.4']])]); diff --git a/tests/Unit/Scaffold/ComposerJsonTest.php b/tests/Unit/Scaffold/ComposerJsonTest.php index 537d7ac..8020106 100644 --- a/tests/Unit/Scaffold/ComposerJsonTest.php +++ b/tests/Unit/Scaffold/ComposerJsonTest.php @@ -153,6 +153,15 @@ function addBlog(ComposerJson $composerJson): void ]); })->with(['packagist.org', 'packagist']); +it('filters a mirror named packagist in the repository list and the default repository', function (string $name) { + // "composer config repo.packagist composer " writes this; Composer still uses the default repository. + $mirror = ['name' => $name, 'type' => 'composer', 'url' => 'https://mirror.example.com']; + + $json = editComposerJson($this->directory, ['repositories' => [$mirror]], addBlog(...)); + + expect($json['repositories'])->toBe([[...$mirror, 'exclude' => ['app/*']], PATH_REPOSITORY, PACKAGIST_EXCLUDING_APP]); +})->with(['packagist.org', 'packagist']); + it('leaves a packagist repository restricted with "only" untouched', function () { $repository = ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'only' => ['laravel/*']]; @@ -165,7 +174,7 @@ function addBlog(ComposerJson $composerJson): void $contents = ['repositories' => [['type' => 'composer', 'url' => 'https://repo.packagist.org', 'only' => ['laravel/*', 'app/*']]]]; expect(fn () => editComposerJson($this->directory, $contents, addBlog(...))) - ->toThrow(ComposerFailed::class, 'Packagist serves [app/blog] through "only"'); + ->toThrow(ComposerFailed::class, 'a Packagist repository serves [app/blog] through "only"'); }); it('does not enable packagist when it is disabled', function (array $disabled) { @@ -382,6 +391,10 @@ function addBlog(ComposerJson $composerJson): void 'replaced by another kind of repository' => [['packagist.org' => ['type' => 'vcs', 'url' => 'https://example.com/repo.git']], true], 'disabled by name, then redefined' => [['packagist.org' => false, 'main' => ['type' => 'composer', 'url' => 'https://repo.packagist.org']], false], 'after entries that are not objects' => [['modules/*', PACKAGIST_EXCLUDING_APP], true], + 'named mirror' => [[['name' => 'packagist', 'type' => 'composer', 'url' => 'https://mirror.example.com'], PACKAGIST_EXCLUDING_APP], false], + 'named mirror excluding the vendor' => [[['name' => 'packagist.org', 'type' => 'composer', 'url' => 'https://mirror.example.com', 'exclude' => ['app/*']], PACKAGIST_EXCLUDING_APP], true], + 'named mirror next to the default repository' => [[['name' => 'packagist.org', 'type' => 'composer', 'url' => 'https://mirror.example.com', 'exclude' => ['app/*']]], false], + 'named mirror restricted to other vendors' => [[['name' => 'packagist', 'type' => 'composer', 'url' => 'https://mirror.example.com', 'only' => ['laravel/*']], ['packagist.org' => false]], true], 'not a composer repository' => [[['type' => 'vcs', 'url' => 'https://packagist.org/app/blog', 'exclude' => ['app/*']]], false], ]); From 83cfaaf4141030f38f1ed357e6ca62a99553ec20 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 10:25:20 +0300 Subject: [PATCH 21/38] Map nested tests and model options of generators The --test option passes the test name as a Stringable, and --model and --parent now name classes of the make:model namespace. --- src/Generators/ModuleOption.php | 48 ++++++++++++++++++++++------- tests/Generators/GeneratorsTest.php | 25 +++++++++++++++ 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/Generators/ModuleOption.php b/src/Generators/ModuleOption.php index c6d393a..9dbde67 100644 --- a/src/Generators/ModuleOption.php +++ b/src/Generators/ModuleOption.php @@ -24,6 +24,7 @@ use Illuminate\Support\Facades\Config; use Laraneat\Modules\Module; use Laraneat\Modules\ModuleRepository; +use Stringable; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -85,7 +86,7 @@ public static function addTo(Command $command): void // A generator called by another one (make:model Post -a) runs in the module of the caller. if (! is_string($name)) { if (($module = $context->module()) !== null) { - ModuleOption::qualifyName($this, $input, $module); + ModuleOption::qualify($this, $input, $module); } return $this->execute($input, $output); @@ -100,7 +101,7 @@ public static function addTo(Command $command): void } return $context->run($module, function () use ($input, $output, $module): int { - ModuleOption::qualifyName($this, $input, $module); + ModuleOption::qualify($this, $input, $module); return $this->execute($input, $output); }); @@ -108,25 +109,50 @@ public static function addTo(Command $command): void } /** - * Apply the "generators" config: "make:controller Post" becomes "Modules\Blog\\Post". + * Apply the "generators" config: "make:controller Post" becomes "Modules\\Blog\\\\Post". * The namespace is relative to the root namespace of the generator in the module, which is - * "Modules\Blog\Tests" for make:test and "Modules\Blog\Database\Seeders" for make:seeder. + * "Modules\\Blog\\Tests" for make:test and "Modules\\Blog\\Database\\Seeders" for make:seeder. + * The "make:model" namespace also applies to the --model and --parent options. * A name in the module namespace is left as it is. */ - public static function qualifyName(Command $command, InputInterface $input, Module $module): void + public static function qualify(Command $command, InputInterface $input, Module $module): void { - $namespace = Config::array('modules.generators', [])[$command->getName()] ?? null; - - if (! $command instanceof GeneratorCommand || ! is_string($namespace) || ! $input->hasArgument('name') || ! is_string($class = $input->getArgument('name'))) { + if (! $command instanceof GeneratorCommand) { return; } - $class = trim(str_replace('/', '\\', $class), '\\'); + $generators = Config::array('modules.generators', []); + $namespace = $generators[$command->getName()] ?? null; - if ($class !== '' && ! str_starts_with($class, $module->namespace.'\\')) { + if (is_string($namespace) && $input->hasArgument('name')) { $root = (fn (): string => $this->rootNamespace())->call($command); - $input->setArgument('name', rtrim($root, '\\').'\\'.trim($namespace, '\\').'\\'.$class); + // The --test option of generators passes a Stringable. + self::qualifyInput($input->getArgument('name'), $module, $root, $namespace, static fn (string $class) => $input->setArgument('name', $class)); + } + + if (is_string($models = $generators['make:model'] ?? null)) { + foreach (['model', 'parent'] as $option) { + if ($input->hasOption($option)) { + self::qualifyInput($input->getOption($option), $module, $module->namespace, $models, static fn (string $class) => $input->setOption($option, $class)); + } + } + } + } + + /** + * @param Closure(string): void $set + */ + private static function qualifyInput(mixed $class, Module $module, string $root, string $namespace, Closure $set): void + { + if (! is_string($class) && ! $class instanceof Stringable) { + return; + } + + $class = trim(str_replace('/', '\\', (string) $class), '\\'); + + if ($class !== '' && ! str_starts_with($class, $module->namespace.'\\')) { + $set(rtrim($root, '\\').'\\'.trim($namespace, '\\').'\\'.$class); } } } diff --git a/tests/Generators/GeneratorsTest.php b/tests/Generators/GeneratorsTest.php index ae3fd01..c7cd50b 100644 --- a/tests/Generators/GeneratorsTest.php +++ b/tests/Generators/GeneratorsTest.php @@ -108,6 +108,27 @@ ); }); +it('looks up --model and --parent in the "make:model" namespace', function () { + $this->files(['config/modules.php' => ' ["make:model" => "Domain\\\\Models"]];']); + $this->reboot(); + + $policy = $this->generate('make:policy ShelfPolicy --model=Shelf --module=blog'); + $controller = $this->generate('make:controller VolumeController --model=Volume --parent=Shelf --no-interaction --module=blog'); + $qualified = $this->generate('make:observer PostObserver --model="Modules\\\\Blog\\\\Models\\\\Post" --module=blog'); + + expect($policy['modules/blog/src/Policies/ShelfPolicy.php'])->toContain('use Modules\\Blog\\Domain\\Models\\Shelf;') + ->and($controller['modules/blog/src/Http/Controllers/VolumeController.php'])->toContain( + 'use Modules\\Blog\\Domain\\Models\\Shelf;', + 'use Modules\\Blog\\Domain\\Models\\Volume;', + ) + ->and(array_keys($controller))->toEqualCanonicalizing([ + 'modules/blog/src/Domain/Models/Shelf.php', + 'modules/blog/src/Domain/Models/Volume.php', + 'modules/blog/src/Http/Controllers/VolumeController.php', + ]) + ->and($qualified['modules/blog/src/Observers/PostObserver.php'])->toContain('use Modules\\Blog\\Models\\Post;'); +}); + it('imports the form requests of a controller from the module', function () { $files = $this->generate('make:controller PostController --model=Post --requests --module=blog'); @@ -150,6 +171,10 @@ 'factory' => ['make:factory PostFactory --module=blog', [ 'modules/blog/database/factories/Testing/PostFactory.php' => ['namespace Modules\\Blog\\Database\\Factories\\Testing;'], ]], + 'test of another generator' => ['make:job PublishPost --test --phpunit --module=blog', [ + 'modules/blog/src/Jobs/PublishPost.php' => ['namespace Modules\\Blog\\Jobs;'], + 'modules/blog/tests/Feature/Api/Jobs/PublishPostTest.php' => ['namespace Modules\\Blog\\Tests\\Feature\\Api\\Jobs;'], + ]], 'component' => ['make:component Forms/Input --module=blog', [ 'modules/blog/src/UI/Components/Forms/Input.php' => ['namespace Modules\\Blog\\UI\\Components\\Forms;', "view('blog::components.forms.input')"], 'modules/blog/resources/views/components/forms/input.blade.php' => ['
'], From 90e6d1b672619eded452e2d630216cccbbe9e295 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 10:25:20 +0300 Subject: [PATCH 22/38] Document named Packagist mirrors and the generator mapping limits --- CHANGELOG.md | 3 ++- README.md | 20 ++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf925e6..81b7798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,8 @@ Version 3 is a rewrite. See [UPGRADE.md](UPGRADE.md) for the upgrade from 2.x. - `--no-update` for `module:make`, `module:sync` and `module:delete`: edit `composer.json` and print the Composer command instead of running it. - `module:sync` adds the `autoload-dev` of the module tests and updates only the modules that changed. -- A Packagist mirror under the `packagist.org` repository key gets the exclusion of the module vendor too. +- Packagist mirrors defined in the project (under the `packagist.org` key, or named `packagist` in the list) + get the exclusion of the module vendor too. - The module manifest is cached by `php artisan optimize` and cleared by `optimize:clear`; `MODULES_CACHE` changes the cache path. - The modules path is added to `octane.watch`. diff --git a/README.md b/README.md index 143c54c..8e3f220 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,12 @@ You do not have to write this by hand: `module:make` and `module:sync` maintain - The Packagist repository excludes the vendor of the modules, so Composer fails instead of installing a public package when a module directory is missing (another branch, a partial checkout). Composer uses this repository in place of the default one. Pick a vendor that you do not publish public packages under. -- A mirror that replaces Packagist under the `packagist.org` key of `"repositories": {...}` gets the - exclusion too. Exclude the vendor by hand in any other repository that proxies Packagist. +- Packagist mirrors defined in the project get the exclusion too: a mirror under the `packagist.org` key of + `"repositories": {...}`, and a list entry named `packagist` or `packagist.org`, which + `composer config repo.packagist composer ` writes. Exclude the vendor by hand in any other repository + that proxies Packagist. +- The Packagist entry of the project also replaces a Packagist mirror set in the global Composer config + (`~/.composer/config.json`). Define such a mirror in the project `composer.json` instead. ### Creating a module @@ -192,8 +196,8 @@ of the application config replace those of the module. knows its model, the way Laravel pairs `App\Models\Post` with `Database\Factories\PostFactory`. Classes outside the modules keep the Laravel behavior. -The resolver is global (`Factory::guessFactoryNamesUsing()`): a resolver that the application sets itself -replaces it. +The resolver is global (`Factory::guessFactoryNamesUsing()`): a resolver that the application sets in the +`boot()` method of its provider replaces it. The factory resolver is registered only in the console (tests, seeders, Tinker), because factories are not used while serving requests. If you create models with factories during HTTP requests, point the model at @@ -280,8 +284,8 @@ Some generators are adjusted to the module: Nested commands run in the same module: `make:model Post --module=blog -mfs` creates the model, the migration, the factory and the seeder in `blog`. -`--model` and `--parent` name models of the module: `--model=Post` is `Modules\Blog\Models\Post`. As in the -application, they can not reference a model of another namespace. +`--model` and `--parent` name models of the module: `--model=Post` is `Modules\Blog\Models\Post`, or a class of +the `make:model` namespace (see below). As in the application, they can not reference a model of another namespace. ### Namespaces @@ -303,6 +307,10 @@ requests there, and the controller imports the requests from `UI\API\Requests`. `make:test`, `Modules\Blog\Database\Factories` for `make:factory`, `Modules\Blog\Database\Seeders` for `make:seeder` and `Modules\Blog` for the others. - A name that starts with the module namespace is used as it is: `make:action "Modules\Blog\Domain\Publish"`. +- The `make:model` namespace also applies to `--model` and `--parent`. The factory of `make:model -f` goes + where the factory resolver looks for it, whatever the `make:factory` namespace. +- `Modules::seeders()` reads only direct subdirectories of `database/seeders`, so a `make:seeder` namespace + has at most one segment. - The `make:command` namespace is also where [module commands](#commands) are discovered, and the `make:component` namespace is where `` components are looked up. From ea9069fcb2228a0b250c1fde85c3d43eae0c52db Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 10:38:51 +0300 Subject: [PATCH 23/38] Exclude the vendor from every repository that serves Packagist A module whose installed directory is missing is an error again, not a copy. --- src/Commands/DoctorCommand.php | 2 +- src/Scaffold/ComposerJson.php | 91 +++++++++----------- tests/Feature/Commands/DoctorCommandTest.php | 10 +++ tests/Unit/Scaffold/ComposerJsonTest.php | 14 +++ 4 files changed, 67 insertions(+), 50 deletions(-) diff --git a/src/Commands/DoctorCommand.php b/src/Commands/DoctorCommand.php index 520a92c..25f1d6c 100644 --- a/src/Commands/DoctorCommand.php +++ b/src/Commands/DoctorCommand.php @@ -117,7 +117,7 @@ private function checkModule(Module $module, array $manifest, ComposerJson $root $source = $installed->pathSource($module->package); // COMPOSER_MIRROR_PATH_REPOS=1 copies path packages, as Docker images often do. - $problems[] = $source !== null && ! is_link($vendorPath) && realpath($composer->absolute($source)) === realpath($module->path) + $problems[] = $source !== null && is_dir($vendorPath) && ! is_link($vendorPath) && realpath($composer->absolute($source)) === realpath($module->path) ? [false, "[{$composer->relative($vendorPath)}] is a copy, not a link to [{$composer->relative($module->path)}]: changes of the module need \"composer update\"."] : [true, "[{$composer->relative($vendorPath)}] is not a link to [{$composer->relative($module->path)}]."]; } elseif (! $installed->isCurrent($module->package, $composerJson->toArray())) { diff --git a/src/Scaffold/ComposerJson.php b/src/Scaffold/ComposerJson.php index 59805b1..d6c9703 100644 --- a/src/Scaffold/ComposerJson.php +++ b/src/Scaffold/ComposerJson.php @@ -169,15 +169,19 @@ public function requires(string $package): bool */ public function isExcludedFromPackagist(string $package): bool { - foreach ($this->namedMirrors() as $mirror) { - if (! $this->excludes($mirror, $package)) { + [$repositories, $default] = $this->packagist(); + + if ($default) { + return false; + } + + foreach ($repositories as $repository) { + if (! $this->excludes($repository, $package)) { return false; } } - $packagist = $this->packagist(); - - return $packagist instanceof stdClass ? $this->excludes($packagist, $package) : $packagist === false; + return true; } public function isDirty(): bool @@ -222,14 +226,11 @@ private function addPathRepository(string $url): void private function excludeFromPackagist(string $package): void { $pattern = strstr($package, '/', true).'/*'; - $repositories = $this->namedMirrors(); - $packagist = $this->packagist(); + [$repositories, $default] = $this->packagist(); - if ($packagist === null) { + if ($default) { // Composer replaces the default Packagist repository with a repository of its URL. $this->addRepository('packagist.org', (object) ['type' => 'composer', 'url' => self::PACKAGIST, 'exclude' => [$pattern]]); - } elseif ($packagist !== false) { - $repositories[] = $packagist; } foreach ($repositories as $repository) { @@ -260,63 +261,55 @@ private function excludes(stdClass $repository, string $package): bool } /** - * Mirrors in the repository list with the "packagist.org" or "packagist" name, as Composer 2.10 - * writes them for "composer config repo.packagist composer ". Composer ignores the name: - * they are used next to the default Packagist repository, not in its place. + * The repositories that serve the packages of Packagist, and whether Composer still uses + * the default Packagist repository. Composer semantics: + * + * - any value under the "packagist.org" or "packagist" key replaces the default repository, + * and false, or a {"packagist.org": false} entry, disables it; + * - a composer repository with a packagist.org URL disables the default repository; + * - a list entry named "packagist.org" or "packagist" (a mirror, as Composer 2.10 writes it) + * is used next to the default repository: Composer ignores the name. * - * @return list + * @return array{list, bool} */ - private function namedMirrors(): array + private function packagist(): array { - $mirrors = []; + $repositories = []; + $default = true; foreach ($this->repositories() as $name => $repository) { - if (is_int($name) - && $repository instanceof stdClass - && ($repository->type ?? null) === 'composer' - && in_array($repository->name ?? null, ['packagist', 'packagist.org'], true)) { - $mirrors[] = $repository; - } - } - - return $mirrors; - } + $slot = is_string($name) && in_array($name, ['packagist', 'packagist.org'], true); - /** - * The repository Composer uses in place of the default Packagist repository: a composer repository - * with a packagist.org URL, or one named "packagist.org" or "packagist" whatever its URL (a mirror). - * False when Packagist is disabled or replaced by another kind of repository, null for the default one. - */ - private function packagist(): stdClass|false|null - { - $disabled = false; + if ($slot) { + $default = false; + } - foreach ($this->repositories() as $name => $repository) { - // A repository under this name takes the place of the default one, false disables it. - if (is_string($name) && in_array($name, ['packagist', 'packagist.org'], true)) { - if ($repository instanceof stdClass && ($repository->type ?? null) === 'composer') { - return $repository; - } + if (! $repository instanceof stdClass) { + continue; + } - $disabled = true; + if (($repository->{'packagist.org'} ?? null) === false || ($repository->packagist ?? null) === false) { + $default = false; continue; } - if (! $repository instanceof stdClass) { + if (($repository->type ?? null) !== 'composer') { continue; } - if (($repository->{'packagist.org'} ?? null) === false || ($repository->packagist ?? null) === false) { - $disabled = true; - } elseif (($repository->type ?? null) === 'composer' - && is_string($repository->url ?? null) - && preg_match('{^https?://(?:[a-z0-9-.]+\.)?packagist\.org(/|$)}', $repository->url) === 1) { - return $repository; + $url = is_string($repository->url ?? null) && preg_match('{^https?://(?:[a-z0-9-.]+\.)?packagist\.org(/|$)}', $repository->url) === 1; + + if ($url) { + $default = false; + } + + if ($slot || $url || (is_int($name) && in_array($repository->name ?? null, ['packagist', 'packagist.org'], true))) { + $repositories[] = $repository; } } - return $disabled ? false : null; + return [$repositories, $default]; } /** diff --git a/tests/Feature/Commands/DoctorCommandTest.php b/tests/Feature/Commands/DoctorCommandTest.php index 2539b39..707cb23 100644 --- a/tests/Feature/Commands/DoctorCommandTest.php +++ b/tests/Feature/Commands/DoctorCommandTest.php @@ -57,6 +57,16 @@ function doctor(): array ->and($output)->toContain('! [vendor/app/blog] is a copy, not a link to [modules/blog]: changes of the module need "composer update".'); }); +it('reports a missing installed package', function () { + $this->installModules(); + unlink($this->path('vendor/app/blog')); + + [$status, $output] = doctor(); + + expect($status)->toBe(1) + ->and($output)->toContain('✗ [vendor/app/blog] is not a link to [modules/blog].'); +}); + it('reports a package installed from another repository', function (array $dist) { $this->installModules(); unlink($this->path('vendor/app/blog')); diff --git a/tests/Unit/Scaffold/ComposerJsonTest.php b/tests/Unit/Scaffold/ComposerJsonTest.php index 8020106..5570a2f 100644 --- a/tests/Unit/Scaffold/ComposerJsonTest.php +++ b/tests/Unit/Scaffold/ComposerJsonTest.php @@ -162,6 +162,19 @@ function addBlog(ComposerJson $composerJson): void expect($json['repositories'])->toBe([[...$mirror, 'exclude' => ['app/*']], PATH_REPOSITORY, PACKAGIST_EXCLUDING_APP]); })->with(['packagist.org', 'packagist']); +it('filters every repository that serves packagist', function () { + $mirror = ['type' => 'composer', 'url' => 'https://mirror.example.com']; + $packagist = ['type' => 'composer', 'url' => 'https://repo.packagist.org']; + + $json = editComposerJson($this->directory, ['repositories' => ['packagist.org' => $mirror, 'main' => $packagist]], addBlog(...)); + + expect($json['repositories'])->toBe([ + 'packagist.org' => [...$mirror, 'exclude' => ['app/*']], + 'main' => [...$packagist, 'exclude' => ['app/*']], + 'modules' => PATH_REPOSITORY, + ]); +}); + it('leaves a packagist repository restricted with "only" untouched', function () { $repository = ['type' => 'composer', 'url' => 'https://repo.packagist.org', 'only' => ['laravel/*']]; @@ -395,6 +408,7 @@ function addBlog(ComposerJson $composerJson): void 'named mirror excluding the vendor' => [[['name' => 'packagist.org', 'type' => 'composer', 'url' => 'https://mirror.example.com', 'exclude' => ['app/*']], PACKAGIST_EXCLUDING_APP], true], 'named mirror next to the default repository' => [[['name' => 'packagist.org', 'type' => 'composer', 'url' => 'https://mirror.example.com', 'exclude' => ['app/*']]], false], 'named mirror restricted to other vendors' => [[['name' => 'packagist', 'type' => 'composer', 'url' => 'https://mirror.example.com', 'only' => ['laravel/*']], ['packagist.org' => false]], true], + 'one of the packagist repositories not excluding the vendor' => [['packagist.org' => ['type' => 'composer', 'url' => 'https://mirror.example.com', 'exclude' => ['app/*']], 'main' => ['type' => 'composer', 'url' => 'https://repo.packagist.org']], false], 'not a composer repository' => [[['type' => 'vcs', 'url' => 'https://packagist.org/app/blog', 'exclude' => ['app/*']]], false], ]); From ab6dfc470d8d4143e4818ce4347b9612405e542e Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 10:38:51 +0300 Subject: [PATCH 24/38] Map --model only for the Laravel generators and allow an empty namespace --- src/Generators/ModuleOption.php | 16 +++++++++-- tests/Generators/GeneratorsTest.php | 43 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/Generators/ModuleOption.php b/src/Generators/ModuleOption.php index 9dbde67..091963e 100644 --- a/src/Generators/ModuleOption.php +++ b/src/Generators/ModuleOption.php @@ -51,6 +51,16 @@ final class ModuleOption BaseViewMakeCommand::class => ViewMakeCommand::class, ]; + /** + * The options of the Laravel generators that name a model. + */ + private const array MODEL_OPTIONS = [ + 'make:controller' => ['model', 'parent'], + 'make:factory' => ['model'], + 'make:observer' => ['model'], + 'make:policy' => ['model'], + ]; + public static function register(Container $app): void { $app->singleton(ModuleContext::class); @@ -122,7 +132,7 @@ public static function qualify(Command $command, InputInterface $input, Module $ } $generators = Config::array('modules.generators', []); - $namespace = $generators[$command->getName()] ?? null; + $namespace = $generators[$command->getName() ?? ''] ?? null; if (is_string($namespace) && $input->hasArgument('name')) { $root = (fn (): string => $this->rootNamespace())->call($command); @@ -132,7 +142,7 @@ public static function qualify(Command $command, InputInterface $input, Module $ } if (is_string($models = $generators['make:model'] ?? null)) { - foreach (['model', 'parent'] as $option) { + foreach (self::MODEL_OPTIONS[$command->getName() ?? ''] ?? [] as $option) { if ($input->hasOption($option)) { self::qualifyInput($input->getOption($option), $module, $module->namespace, $models, static fn (string $class) => $input->setOption($option, $class)); } @@ -152,7 +162,7 @@ private static function qualifyInput(mixed $class, Module $module, string $root, $class = trim(str_replace('/', '\\', (string) $class), '\\'); if ($class !== '' && ! str_starts_with($class, $module->namespace.'\\')) { - $set(rtrim($root, '\\').'\\'.trim($namespace, '\\').'\\'.$class); + $set(implode('\\', array_filter([rtrim($root, '\\'), trim($namespace, '\\'), $class], static fn (string $part): bool => $part !== ''))); } } } diff --git a/tests/Generators/GeneratorsTest.php b/tests/Generators/GeneratorsTest.php index c7cd50b..9400c67 100644 --- a/tests/Generators/GeneratorsTest.php +++ b/tests/Generators/GeneratorsTest.php @@ -129,6 +129,49 @@ ->and($qualified['modules/blog/src/Observers/PostObserver.php'])->toContain('use Modules\\Blog\\Models\\Post;'); }); +it('maps to the module root with an empty namespace', function () { + $this->files(['config/modules.php' => ' ["make:model" => "", "make:policy" => ""]];']); + $this->reboot(); + + $files = $this->generate('make:policy RackPolicy --model=Rack --module=blog'); + + expect($files['modules/blog/src/RackPolicy.php'])->toContain('namespace Modules\\Blog;', 'use Modules\\Blog\\Rack;'); +}); + +it('leaves the --model option of other generators alone', function () { + $this->files(['config/modules.php' => ' ["make:model" => "Domain\\\\Models"]];']); + $this->reboot(); + + $command = new class(app('files')) extends GeneratorCommand + { + protected $name = 'make:prompt'; + + protected function getStub(): string + { + return __FILE__; + } + + public function handle(): int + { + $this->line('model: '.$this->option('model')); + + return self::SUCCESS; + } + + protected function getOptions(): array + { + return [['model', null, InputOption::VALUE_REQUIRED, 'The language model']]; + } + }; + + ModuleOption::addTo($command); + Artisan::registerCommand($command); + + $this->artisan('make:prompt Summary --model=gpt-4o --module=blog') + ->expectsOutputToContain('model: gpt-4o') + ->assertSuccessful(); +}); + it('imports the form requests of a controller from the module', function () { $files = $this->generate('make:controller PostController --model=Post --requests --module=blog'); From 00a36f7b0e4eb17ec2ceb4e1e23a59cd630fbb21 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 10:38:51 +0300 Subject: [PATCH 25/38] Read command input without relying on inferred types Also correct the README on global Packagist mirrors. --- README.md | 8 +++++--- src/Commands/DeleteCommand.php | 3 ++- src/Commands/MakeCommand.php | 8 ++++++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 8e3f220..bf9ea4d 100644 --- a/README.md +++ b/README.md @@ -107,8 +107,10 @@ You do not have to write this by hand: `module:make` and `module:sync` maintain `"repositories": {...}`, and a list entry named `packagist` or `packagist.org`, which `composer config repo.packagist composer ` writes. Exclude the vendor by hand in any other repository that proxies Packagist. -- The Packagist entry of the project also replaces a Packagist mirror set in the global Composer config - (`~/.composer/config.json`). Define such a mirror in the project `composer.json` instead. +- A Packagist mirror of the global Composer config (`~/.composer/config.json`) is not edited: define it in + the project `composer.json` instead. The Packagist entry of the project replaces a global mirror under the + `packagist.org` key; a global mirror that Composer 2.10 wrote as a named list entry is still used, without + the exclusion. ### Creating a module @@ -307,7 +309,7 @@ requests there, and the controller imports the requests from `UI\API\Requests`. `make:test`, `Modules\Blog\Database\Factories` for `make:factory`, `Modules\Blog\Database\Seeders` for `make:seeder` and `Modules\Blog` for the others. - A name that starts with the module namespace is used as it is: `make:action "Modules\Blog\Domain\Publish"`. -- The `make:model` namespace also applies to `--model` and `--parent`. The factory of `make:model -f` goes +- The `make:model` namespace also applies to `--model` and `--parent` of the Laravel generators. The factory of `make:model -f` goes where the factory resolver looks for it, whatever the `make:factory` namespace. - `Modules::seeders()` reads only direct subdirectories of `database/seeders`, so a `make:seeder` namespace has at most one segment. diff --git a/src/Commands/DeleteCommand.php b/src/Commands/DeleteCommand.php index 13ac198..af042d6 100644 --- a/src/Commands/DeleteCommand.php +++ b/src/Commands/DeleteCommand.php @@ -27,7 +27,8 @@ public function handle( Filesystem $files, ): int { try { - $module = $modules->get($this->argument('name')); + $name = $this->input->getArgument('name'); + $module = $modules->get(is_string($name) ? $name : ''); if (is_link($module->path)) { $this->components->error("[{$composer->relative($module->path)}] is a symbolic link. Remove the module by hand."); diff --git a/src/Commands/MakeCommand.php b/src/Commands/MakeCommand.php index 9cc7f58..f44279f 100644 --- a/src/Commands/MakeCommand.php +++ b/src/Commands/MakeCommand.php @@ -34,7 +34,8 @@ public function handle( Filesystem $files, ): int { try { - $name = Names::module($this->argument('name')); + $name = $this->input->getArgument('name'); + $name = Names::module(is_string($name) ? $name : ''); $package = Names::package(Config::string('modules.vendor'), $name); $path = $modules->modulesPath().'/'.$name; @@ -44,7 +45,10 @@ public function handle( return self::FAILURE; } - $rendered = $renderer->render($this->template($preset = $this->option('preset') ?? 'default'), [ + $preset = $this->input->getOption('preset'); + $preset = is_string($preset) ? $preset : 'default'; + + $rendered = $renderer->render($this->template($preset), [ 'name' => $name, 'namespace' => Names::namespace(Config::string('modules.namespace'), $name), 'package' => $package, From 445fd1d5e50be6f58267762dd3d2df5d3e5bbdb7 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 12:33:43 +0300 Subject: [PATCH 26/38] Check module files for deletion only when the manifest is cached A built manifest lists the files on disk, so development requests without the config and route caches no longer stat every module route and config file. --- README.md | 3 ++- src/ModulesServiceProvider.php | 8 +++++++- src/Registrars/ResourceRegistrar.php | 8 ++++++-- src/Registrars/RouteRegistrar.php | 8 ++++++-- tests/Feature/Conventions/ConfigTest.php | 13 +++++++++++++ tests/Feature/Conventions/RoutesTest.php | 14 ++++++++++++++ 6 files changed, 48 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bf9ea4d..3745818 100644 --- a/README.md +++ b/README.md @@ -449,7 +449,8 @@ provider only reads the manifest, it never scans the modules while it boots. - Without the cache file, the manifest is built once per process, so it is never stale. The cache is written only by `optimize` and `module:cache`: a deploy that skips them scans the modules in every process. - The cached manifest lists the files it loads. After `php artisan optimize` on a development machine, new - route, config, seeder and command files are ignored until `php artisan optimize:clear`. + route, config, seeder and command files are ignored until `php artisan optimize:clear`, and deleted ones + are skipped. Only a cached manifest is checked for deleted files: a built one lists the files on disk. - `module:make`, `module:sync` and `module:delete` rebuild the cache file if it exists. - The config and route caches include the module config and routes, so the package skips them when they are cached. diff --git a/src/ModulesServiceProvider.php b/src/ModulesServiceProvider.php index 00a4c03..883dd2b 100644 --- a/src/ModulesServiceProvider.php +++ b/src/ModulesServiceProvider.php @@ -36,6 +36,7 @@ public function register(): void (new ResourceRegistrar( $this->app, $this->manifest(), + $this->isCached(), Config::string('modules.generators.make:component', 'View\\Components'), ))->register(); @@ -52,7 +53,7 @@ public function boot(): void $manifest = $this->manifest(); if (! $this->app->routesAreCached()) { - (new RouteRegistrar($this->app->make('router'), Config::array('modules.routes', []), $manifest))->register(); + (new RouteRegistrar($this->app->make('router'), Config::array('modules.routes', []), $manifest, $this->isCached()))->register(); } if ($this->app->runningInConsole()) { @@ -103,6 +104,11 @@ private function manifest(): array return $this->app->make(ModuleRepository::class)->manifest(); } + private function isCached(): bool + { + return $this->app->make(ModuleRepository::class)->isCached(); + } + private static function manifestBuilder(Application $app): ManifestBuilder { $routes = []; diff --git a/src/Registrars/ResourceRegistrar.php b/src/Registrars/ResourceRegistrar.php index fd712c2..9b3154a 100644 --- a/src/Registrars/ResourceRegistrar.php +++ b/src/Registrars/ResourceRegistrar.php @@ -33,11 +33,13 @@ { /** * @param Manifest $manifest + * @param bool $cached Whether the manifest was read from the cache file: config files deleted since it was written are skipped. * @param string $componentsNamespace The namespace of Blade components inside a module. */ public function __construct( private Application $app, private array $manifest, + private bool $cached = false, private string $componentsNamespace = 'View\\Components', ) {} @@ -49,7 +51,6 @@ public function register(): void /** * Merge the config files of the modules; the application config wins. - * A file missing since the manifest was cached is skipped. */ private function registerConfig(): void { @@ -61,7 +62,10 @@ private function registerConfig(): void foreach ($this->manifest as $module) { foreach ($module['config'] as $key) { - if (! is_file($file = $module['path'].'/config/'.$key.'.php')) { + $file = $module['path'].'/config/'.$key.'.php'; + + // A built manifest lists existing files, so only a cached one costs a check per file. + if ($this->cached && ! is_file($file)) { continue; } diff --git a/src/Registrars/RouteRegistrar.php b/src/Registrars/RouteRegistrar.php index bd6c97b..f666367 100644 --- a/src/Registrars/RouteRegistrar.php +++ b/src/Registrars/RouteRegistrar.php @@ -20,11 +20,13 @@ /** * @param array $groups The "routes" config. * @param Manifest $manifest + * @param bool $cached Whether the manifest was read from the cache file: route files deleted since it was written are skipped. */ public function __construct( private Router $router, private array $groups, private array $manifest, + private bool $cached = false, ) {} public function register(): void @@ -44,8 +46,10 @@ public function register(): void : ['prefix' => ltrim($prefix.'/'.$directory, '/')] + $attributes; foreach ($files as $file) { - // A file deleted since the manifest was cached is skipped. - if (is_file($path = $module['path'].'/'.$file)) { + $path = $module['path'].'/'.$file; + + // A built manifest lists existing files, so only a cached one costs a check per file. + if (! $this->cached || is_file($path)) { $this->router->group($directoryAttributes, $path); } } diff --git a/tests/Feature/Conventions/ConfigTest.php b/tests/Feature/Conventions/ConfigTest.php index a37bb5d..d99f8ca 100644 --- a/tests/Feature/Conventions/ConfigTest.php +++ b/tests/Feature/Conventions/ConfigTest.php @@ -49,3 +49,16 @@ public function boot(): void expect(fn () => $this->reboot())->toThrow(InvalidModule::class, 'config/broken.php must return an array.'); }); + +it('checks the config files only of a cached manifest', function (bool $cached) { + $manifest = app(ModuleRepository::class)->manifest(); + array_unshift($manifest['blog']['config'], 'deleted'); + config(['blog' => null]); + + $register = fn () => (new ResourceRegistrar($this->app, $manifest, $cached))->register(); + + $cached + ? expect($register)->not->toThrow(Throwable::class) + ->and(config('blog.title'))->toBe('Blog') + : expect($register)->toThrow(ErrorException::class, 'config/deleted.php'); +})->with(['cached' => true, 'built' => false]); diff --git a/tests/Feature/Conventions/RoutesTest.php b/tests/Feature/Conventions/RoutesTest.php index e325dcb..e533d15 100644 --- a/tests/Feature/Conventions/RoutesTest.php +++ b/tests/Feature/Conventions/RoutesTest.php @@ -78,3 +78,17 @@ function moduleRoutes(): array expect(array_map(static fn (RouteObject $route): string => $route->uri(), $router->getRoutes()->getRoutes()))->toBe(['blog', 'orders']); }); + +it('checks the route files only of a cached manifest', function (bool $cached) { + app()->instance('router', $router = new Router(app('events'), app())); + Route::clearResolvedInstance('router'); + $manifest = app(ModuleRepository::class)->manifest(); + $manifest['blog']['routes']['web'][''][] = 'routes/web/deleted.php'; + + $register = fn () => (new RouteRegistrar($router, config('modules.routes'), $manifest, $cached))->register(); + + $cached + ? expect($register)->not->toThrow(Throwable::class) + ->and(array_map(static fn (RouteObject $route): string => $route->uri(), $router->getRoutes()->getRoutes()))->toContain('api/posts', 'blog') + : expect($register)->toThrow(ErrorException::class, 'routes/web/deleted.php'); +})->with(['cached' => true, 'built' => false]); From 8efcdf0ff266ed6187ba351f8fdb8090610a4e8d Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 19:30:47 +0300 Subject: [PATCH 27/38] Keep the tests and names working on PHP 8.6 PHP 8.6 deprecates a class constant called namespace and no longer puts the path in the warning of a failed require. --- src/Scaffold/Names.php | 6 +++--- tests/Feature/Conventions/ConfigTest.php | 2 +- tests/Feature/Conventions/RoutesTest.php | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Scaffold/Names.php b/src/Scaffold/Names.php index f1754be..cbc3f9e 100644 --- a/src/Scaffold/Names.php +++ b/src/Scaffold/Names.php @@ -21,7 +21,7 @@ final class Names private const string MODULE = '{^[a-z][a-z0-9]*(-[a-z0-9]+)*$}D'; - private const string NAMESPACE = '{^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)*$}D'; + private const string PHP_NAMESPACE = '{^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)*$}D'; /** * Normalize a module name to kebab case: "ShopOrder", "shop_order" and "shop-order" are "shop-order". @@ -72,7 +72,7 @@ public static function namespace(string $prefix, string $module): string { $prefix = trim($prefix, '\\'); - if (preg_match(self::NAMESPACE, $prefix) !== 1) { + if (preg_match(self::PHP_NAMESPACE, $prefix) !== 1) { throw InvalidName::of('namespace', $prefix, 'it is not a valid PHP namespace.'); } @@ -81,6 +81,6 @@ public static function namespace(string $prefix, string $module): string public static function isNamespace(string $namespace): bool { - return preg_match(self::NAMESPACE, $namespace) === 1; + return preg_match(self::PHP_NAMESPACE, $namespace) === 1; } } diff --git a/tests/Feature/Conventions/ConfigTest.php b/tests/Feature/Conventions/ConfigTest.php index d99f8ca..6935384 100644 --- a/tests/Feature/Conventions/ConfigTest.php +++ b/tests/Feature/Conventions/ConfigTest.php @@ -60,5 +60,5 @@ public function boot(): void $cached ? expect($register)->not->toThrow(Throwable::class) ->and(config('blog.title'))->toBe('Blog') - : expect($register)->toThrow(ErrorException::class, 'config/deleted.php'); + : expect($register)->toThrow(ErrorException::class, 'Failed to open stream'); })->with(['cached' => true, 'built' => false]); diff --git a/tests/Feature/Conventions/RoutesTest.php b/tests/Feature/Conventions/RoutesTest.php index e533d15..02559b1 100644 --- a/tests/Feature/Conventions/RoutesTest.php +++ b/tests/Feature/Conventions/RoutesTest.php @@ -90,5 +90,5 @@ function moduleRoutes(): array $cached ? expect($register)->not->toThrow(Throwable::class) ->and(array_map(static fn (RouteObject $route): string => $route->uri(), $router->getRoutes()->getRoutes()))->toContain('api/posts', 'blog') - : expect($register)->toThrow(ErrorException::class, 'routes/web/deleted.php'); + : expect($register)->toThrow(ErrorException::class, 'Failed to open stream'); })->with(['cached' => true, 'built' => false]); From a22dd4227eecd2cf23cc49d5f2ddd42b1ba128d7 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 19:30:47 +0300 Subject: [PATCH 28/38] Install the CI dependencies on Windows and PHP 8.6 Windows needs the fileinfo extension and PHP 8.6 is newer than the versions packages declare. Laravel 14 is tested again once Testbench and Octane support it. --- .github/workflows/tests.yml | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3fc2436..504271e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,15 +24,13 @@ jobs: os: [ubuntu-latest] php: ['8.3', '8.4', '8.5'] dependencies: [stable] - framework: [''] experimental: [false] include: - - { os: ubuntu-latest, php: '8.3', dependencies: lowest, framework: '', experimental: false } - - { os: windows-latest, php: '8.4', dependencies: stable, framework: '', experimental: true } - - { os: ubuntu-latest, php: '8.6', dependencies: stable, framework: '', experimental: true } - - { os: ubuntu-latest, php: '8.5', dependencies: stable, framework: '14.x-dev as 13.999.0', experimental: true } + - { os: ubuntu-latest, php: '8.3', dependencies: lowest, experimental: false } + - { os: windows-latest, php: '8.4', dependencies: stable, experimental: true } + - { os: ubuntu-latest, php: '8.6', dependencies: stable, experimental: true } - name: PHP ${{ matrix.php }} - ${{ matrix.dependencies }}${{ matrix.framework && ' - Laravel 14.x-dev' || '' }}${{ matrix.os == 'windows-latest' && ' - Windows' || '' }} + name: PHP ${{ matrix.php }} - ${{ matrix.dependencies }}${{ matrix.os == 'windows-latest' && ' - Windows' || '' }} steps: - name: Checkout code @@ -44,15 +42,12 @@ jobs: uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, json, pdo_sqlite + extensions: dom, curl, fileinfo, libxml, mbstring, zip, json, pdo_sqlite coverage: none - - name: Require the Laravel version - if: matrix.framework != '' - run: composer require "laravel/framework:${{ matrix.framework }}" --no-interaction --no-update - + # Packages declare the PHP versions they support, and a PHP release in development is newer than all of them. - name: Install dependencies - run: composer update --prefer-dist --no-interaction ${{ matrix.dependencies == 'lowest' && '--prefer-lowest --prefer-stable' || '' }} + run: composer update --prefer-dist --no-interaction ${{ matrix.dependencies == 'lowest' && '--prefer-lowest --prefer-stable' || '' }} ${{ matrix.php == '8.6' && '--ignore-platform-req=php+' || '' }} - name: Run tests run: vendor/bin/pest --parallel --order-by=random From 36218d57d79c7a98fe20f1f2b4ef7b599d2bb87b Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 19:49:30 +0300 Subject: [PATCH 29/38] Run the tests on Windows Links are absolute like the junctions Composer creates there, paths and line endings are compared independently of the platform, and templates with "|" in file names, which Windows does not allow, are skipped. --- tests/Feature/Commands/ListCommandTest.php | 2 +- tests/Feature/Commands/MakeCommandTest.php | 4 ++-- tests/Feature/ProviderTest.php | 14 +++++++------- tests/Generators/GeneratorsTestCase.php | 5 +++-- tests/TestCase.php | 3 ++- tests/Unit/Manifest/ManifestBuilderTest.php | 2 +- tests/Unit/Scaffold/TemplateRendererTest.php | 6 +++++- 7 files changed, 21 insertions(+), 15 deletions(-) diff --git a/tests/Feature/Commands/ListCommandTest.php b/tests/Feature/Commands/ListCommandTest.php index 9a1a1e1..b58f3ce 100644 --- a/tests/Feature/Commands/ListCommandTest.php +++ b/tests/Feature/Commands/ListCommandTest.php @@ -8,7 +8,7 @@ function listModules(array $options = []): string { Artisan::call('module:list', $options); - return preg_replace('/ +$/m', '', Artisan::output()); + return preg_replace('/ +$/m', '', str_replace("\r\n", "\n", Artisan::output())); } it('lists the modules', function () { diff --git a/tests/Feature/Commands/MakeCommandTest.php b/tests/Feature/Commands/MakeCommandTest.php index 33a9629..47198c6 100644 --- a/tests/Feature/Commands/MakeCommandTest.php +++ b/tests/Feature/Commands/MakeCommandTest.php @@ -37,7 +37,7 @@ 'autoload-dev' => ['psr-4' => ['Modules\\WikiPages\\Tests\\' => 'tests/']], ]) ->and(collect(File::allFiles($this->path('modules/wiki-pages'), true)) - ->map(fn (SplFileInfo $file): string => $file->getRelativePathname())->sort()->values()->all()) + ->map(fn (SplFileInfo $file): string => str_replace('\\', '/', $file->getRelativePathname()))->sort()->values()->all()) ->toBe([ 'composer.json', 'database/factories/.gitkeep', @@ -134,7 +134,7 @@ ->toBe('and(file_get_contents($this->path('modules/shop-items/README.md')))->toBe('# {{ name }} stays as is') ->and($this->readJson('modules/shop-items/composer.json')['extra'])->toBe(['module' => 'ShopItems', 'vendor' => 'app']); -}); +})->skip(DIRECTORY_SEPARATOR === '\\', 'Windows does not allow "|" in file names.'); it('prefers the published default template', function () { $this->files([ diff --git a/tests/Feature/ProviderTest.php b/tests/Feature/ProviderTest.php index 865d925..af9cf03 100644 --- a/tests/Feature/ProviderTest.php +++ b/tests/Feature/ProviderTest.php @@ -25,7 +25,7 @@ it('merges its config with the config of the application', function () { expect(config('modules'))->toBe([ - 'path' => $this->path('modules'), + 'path' => base_path('modules'), 'namespace' => 'Modules', 'vendor' => 'app', 'routes' => [ @@ -48,9 +48,9 @@ ->all(); expect($published('modules-config')) - ->toBe([realpath(__DIR__.'/../../config/modules.php') => $this->path('config/modules.php')]) + ->toBe([realpath(__DIR__.'/../../config/modules.php') => config_path('modules.php')]) ->and($published('modules-stubs')) - ->toBe([realpath(__DIR__.'/../../resources/stubs/module/default') => $this->path('stubs/module/default')]); + ->toBe([realpath(__DIR__.'/../../resources/stubs/module/default') => base_path('stubs/module/default')]); $this->artisan('vendor:publish --tag=modules-stubs')->assertSuccessful(); @@ -145,11 +145,11 @@ $this->artisan('module:cache')->assertSuccessful(); - expect(app(ModuleRepository::class)->cachePath())->toBe(str_replace('{base}', $this->basePath, $expected)) - ->and(file_exists(str_replace('{base}', $this->basePath, $expected)))->toBeTrue(); + expect(app(ModuleRepository::class)->cachePath())->toBe($expected) + ->and(file_exists($expected))->toBeTrue(); })->with([ - 'relative' => ['storage/modules.php', '{base}/storage/modules.php'], - 'absolute' => [fn () => $this->path('storage/framework/modules.php'), '{base}/storage/framework/modules.php'], + 'relative' => ['storage/modules.php', fn () => base_path('storage/modules.php')], + 'absolute' => [fn () => $this->path('storage/framework/modules.php'), fn () => $this->path('storage/framework/modules.php')], ]); final class ModuleProviderBootingFirst extends ServiceProvider diff --git a/tests/Generators/GeneratorsTestCase.php b/tests/Generators/GeneratorsTestCase.php index fee394c..c1b4862 100644 --- a/tests/Generators/GeneratorsTestCase.php +++ b/tests/Generators/GeneratorsTestCase.php @@ -22,7 +22,8 @@ protected function getPackageProviders($app): array } /** - * Run a generator and return the files it created, with migration dates as "{date}". + * Run a generator and return the files it created, with migration dates as "{date}". Line endings are + * normalized: Laravel joins some generated lines with PHP_EOL. * * @return array Contents by path relative to the application. */ @@ -35,7 +36,7 @@ protected function generate(string $command): array $files = []; foreach (array_diff($this->applicationFiles(), $before) as $path) { - $files[(string) preg_replace('/\d{4}_\d{2}_\d{2}_\d{6}_/', '{date}_', $path)] = (string) file_get_contents($this->path($path)); + $files[(string) preg_replace('/\d{4}_\d{2}_\d{2}_\d{6}_/', '{date}_', $path)] = str_replace("\r\n", "\n", (string) file_get_contents($this->path($path))); } return $files; diff --git a/tests/TestCase.php b/tests/TestCase.php index 1725fac..f18127b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -181,7 +181,8 @@ protected function installModules(array $modules = ['blog', 'shop-order']): void ]; (new Filesystem)->ensureDirectoryExists($this->path('vendor/app')); - symlink('../../modules/'.$name, $this->path('vendor/app/'.$name)); + // Composer links path repositories with a relative symlink, or with an absolute junction on Windows. + symlink(DIRECTORY_SEPARATOR === '\\' ? $this->path('modules/'.$name) : '../../modules/'.$name, $this->path('vendor/app/'.$name)); } $root->save(); diff --git a/tests/Unit/Manifest/ManifestBuilderTest.php b/tests/Unit/Manifest/ManifestBuilderTest.php index c4a8110..ae5c5ec 100644 --- a/tests/Unit/Manifest/ManifestBuilderTest.php +++ b/tests/Unit/Manifest/ManifestBuilderTest.php @@ -18,7 +18,7 @@ function moduleComposerJson(string $name, array $psr4 = ['Modules\\Demo\\' => 's } it('describes every module of the fixture application', function () { - $modules = realpath(FIXTURE_MODULES); + $modules = str_replace('\\', '/', (string) realpath(FIXTURE_MODULES)); expect(manifestBuilder($modules)->build())->toBe([ 'blog' => [ diff --git a/tests/Unit/Scaffold/TemplateRendererTest.php b/tests/Unit/Scaffold/TemplateRendererTest.php index b81eaee..be0a511 100644 --- a/tests/Unit/Scaffold/TemplateRendererTest.php +++ b/tests/Unit/Scaffold/TemplateRendererTest.php @@ -67,9 +67,13 @@ 'database/migrations/2026_09_23_120000_create_article_categories_table.php' => ' 'namespace Modules\\ArticleCategory\\ArticleCategory;', ]); -}); +})->skip(DIRECTORY_SEPARATOR === '\\', 'Windows does not allow "|" in file names.'); it('fails on unknown variables and filters before anything is written', function (string $file, string $contents, string $reason) { + if (DIRECTORY_SEPARATOR === '\\' && str_contains($file, '|')) { + $this->markTestSkipped('Windows does not allow "|" in file names.'); + } + $template = $this->files([$file => $contents]); expect(fn () => (new TemplateRenderer)->render($template, TEMPLATE_VARIABLES)) From 7962bf326f7ecba8a180c61b1f2dd7cb10f648ed Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 20:00:27 +0300 Subject: [PATCH 30/38] Remove the vendor link of a deleted module on Windows Filesystem::delete() can not remove a link to a directory there, which is how Composer links path packages. --- src/Commands/DeleteCommand.php | 5 +++-- tests/Feature/Commands/DoctorCommandTest.php | 18 +++++++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/Commands/DeleteCommand.php b/src/Commands/DeleteCommand.php index af042d6..6c33efd 100644 --- a/src/Commands/DeleteCommand.php +++ b/src/Commands/DeleteCommand.php @@ -75,8 +75,9 @@ public function handle( } // Composer does not remove the link of a path package whose directory is gone. - if (is_link($vendorLink) && ! file_exists($vendorLink)) { - $files->delete($vendorLink); + // Windows removes a link to a directory, like the junction Composer creates there, with rmdir(). + if (is_link($vendorLink) && ! file_exists($vendorLink) && ! $files->delete($vendorLink)) { + @rmdir($vendorLink); } $this->components->info("Module [{$module->name}] deleted."); diff --git a/tests/Feature/Commands/DoctorCommandTest.php b/tests/Feature/Commands/DoctorCommandTest.php index 707cb23..9735cde 100644 --- a/tests/Feature/Commands/DoctorCommandTest.php +++ b/tests/Feature/Commands/DoctorCommandTest.php @@ -8,7 +8,15 @@ function doctor(): array { $status = Artisan::call('module:doctor'); - return [$status, preg_replace('/ +$/m', '', Artisan::output())]; + return [$status, preg_replace('/ +$/m', '', str_replace("\r\n", "\n", Artisan::output()))]; +} + +/** + * Windows removes a link to a directory with rmdir(). + */ +function removeLink(string $path): void +{ + DIRECTORY_SEPARATOR === '\\' ? rmdir($path) : unlink($path); } it('finds no problems in installed modules', function () { @@ -37,7 +45,7 @@ function doctor(): array it('reports a package linked elsewhere', function () { $this->installModules(); - unlink($this->path('vendor/app/blog')); + removeLink($this->path('vendor/app/blog')); symlink($this->path('modules/shop-order'), $this->path('vendor/app/blog')); [$status, $output] = doctor(); @@ -48,7 +56,7 @@ function doctor(): array it('warns about a mirrored package', function () { $this->installModules(); - unlink($this->path('vendor/app/blog')); + removeLink($this->path('vendor/app/blog')); mkdir($this->path('vendor/app/blog')); [$status, $output] = doctor(); @@ -59,7 +67,7 @@ function doctor(): array it('reports a missing installed package', function () { $this->installModules(); - unlink($this->path('vendor/app/blog')); + removeLink($this->path('vendor/app/blog')); [$status, $output] = doctor(); @@ -69,7 +77,7 @@ function doctor(): array it('reports a package installed from another repository', function (array $dist) { $this->installModules(); - unlink($this->path('vendor/app/blog')); + removeLink($this->path('vendor/app/blog')); mkdir($this->path('vendor/app/blog')); $installed = $this->readJson('vendor/composer/installed.json'); $installed['packages'][0]['dist'] = $dist; From b57b426be4d0e4ef5c454e15e67462d15a7c1d5b Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 20:11:00 +0300 Subject: [PATCH 31/38] Clear the stat cache before removing the vendor link of a deleted module --- src/Commands/DeleteCommand.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Commands/DeleteCommand.php b/src/Commands/DeleteCommand.php index 6c33efd..b626fa4 100644 --- a/src/Commands/DeleteCommand.php +++ b/src/Commands/DeleteCommand.php @@ -75,7 +75,10 @@ public function handle( } // Composer does not remove the link of a path package whose directory is gone. - // Windows removes a link to a directory, like the junction Composer creates there, with rmdir(). + // The realpath cache may still resolve the link to the deleted directory. Windows removes a link + // to a directory, like the junction Composer creates there, with rmdir(). + clearstatcache(true); + if (is_link($vendorLink) && ! file_exists($vendorLink) && ! $files->delete($vendorLink)) { @rmdir($vendorLink); } From ed38d778b1b2bd9647df50a8fe8d1c1300ffa6a6 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Wed, 23 Sep 2026 20:22:56 +0300 Subject: [PATCH 32/38] Detect a dangling vendor link with is_dir(), which works on Windows --- src/Commands/DeleteCommand.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Commands/DeleteCommand.php b/src/Commands/DeleteCommand.php index b626fa4..ad91487 100644 --- a/src/Commands/DeleteCommand.php +++ b/src/Commands/DeleteCommand.php @@ -75,11 +75,9 @@ public function handle( } // Composer does not remove the link of a path package whose directory is gone. - // The realpath cache may still resolve the link to the deleted directory. Windows removes a link - // to a directory, like the junction Composer creates there, with rmdir(). - clearstatcache(true); - - if (is_link($vendorLink) && ! file_exists($vendorLink) && ! $files->delete($vendorLink)) { + // On Windows, file_exists() is true for a link to a deleted directory, and such a link, like the + // junction Composer creates there, is removed with rmdir(). + if (is_link($vendorLink) && ! is_dir($vendorLink) && ! $files->delete($vendorLink)) { @rmdir($vendorLink); } From 397bd24f038f14c1ab29a8e3a980b3b12c0d3936 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Thu, 24 Sep 2026 05:41:48 +0300 Subject: [PATCH 33/38] Explain why the package exists and add a quick start to the README Compare it with InterNACHI/modular and nWidart/laravel-modules, document dependencies between modules, the Modules facade and the anonymous Blade components, and fix the broken Commands link and the statements that did not match the code. --- README.md | 210 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 169 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 3745818..604b169 100644 --- a/README.md +++ b/README.md @@ -10,19 +10,51 @@ php artisan make:model Post --module=blog -mfs php artisan make:controller PostController --module=blog --api ``` -- **No boilerplate in modules.** A module needs only a `composer.json`. It gets its own service provider only - when it has bindings, policies or events to register. +## Why + +In a Laravel application the code is grouped by type: one feature is spread over `app/Models`, +`app/Http/Controllers`, `database/migrations`, `routes/api.php` and `resources/views`, next to every other +feature. A modular monolith groups the code by feature instead and is still one application with one deploy. +Laravel has no built-in support for it: something has to autoload every module, load its routes, views, +translations, migrations and commands, and make the generators write into it. This package does that, and +stays out of the module code: + +- **Modules are plain Composer packages.** A module needs only a `composer.json` with its autoload and + dependencies. Its code does not use this package: no base classes, no `module.json`, no required service + provider. It gets a provider only when it has bindings, event listeners or gates to register. - **Laravel-native.** Modules use the Laravel generators, stubs and conventions (`Models`, `Http\Controllers`, ...) - unless you map them elsewhere. + unless you map them elsewhere. `migrate`, `route:cache`, `optimize`, Tinker and Octane work as usual. - **Fast.** The package scans the modules once and caches the result with `php artisan optimize`. A cached application does no filesystem scans, and HTTP requests never run the console-only parts. - **Safe with Composer.** Modules are installed as symlinked path packages; their vendor is excluded from - Packagist, so a missing module can never be replaced by a public package with the same name. + Packagist, so a missing module cannot be replaced by a public package with the same name. + `module:doctor` checks the whole setup. + +### Compared to other packages + +As of September 2026: + +| | Laraneat Modules | [InterNACHI/modular](https://github.com/InterNACHI/modular) | [nWidart/laravel-modules](https://github.com/nWidart/laravel-modules) | +|---------------------------|---------------------------------------------------------------|--------------------------------------------|------------------------------------------------| +| Laravel | 13.12+ | 11–13 | 13 (older majors: 5.4–12) | +| A module is | a Composer path package | a Composer path package | a directory with `module.json`; its `composer.json` is merged by `composer-merge-plugin` | +| Service provider per module | not needed | not needed | generated; extends a provider class of the package and loads the routes, views and config of the module | +| Generators | `--module` on every `GeneratorCommand`, of Laravel and of other packages | `--module` on a list of Laravel generators | own `module:make-*` commands and stubs | +| Routes | route groups with a prefix and middleware from the config | `routes/*.php` loaded as they are | route service provider of the module | +| Module config files | merged | not loaded | loaded by the module provider | +| Composer | `module:make`, `module:sync` and `module:delete` run it; Packagist exclusion | the application `composer.json` is edited, you run Composer | `composer-merge-plugin` | +| Event discovery | no | yes | yes, in the generated event provider | +| Enable and disable modules | no | no | yes | + +Choose nWidart/laravel-modules to enable and disable modules or to build module assets with their own Vite +config, and InterNACHI/modular on Laravel 11 or 12. ## Contents +- [Why](#why) - [Requirements](#requirements) - [Installation](#installation) +- [Quick start](#quick-start) - [Modules](#modules) - [Conventions](#conventions) - [Routes](#routes) @@ -55,6 +87,50 @@ when you want to change the defaults: php artisan vendor:publish --tag=modules-config ``` +## Quick start + +Create a module, a model with its migration, factory and seeder, a controller and a test: + +```bash +php artisan module:make blog +php artisan make:model Post --module=blog -mfs +php artisan make:controller PostController --module=blog --api --model=Post +php artisan make:test PostTest --module=blog +``` + +Add a route file, then migrate: + +```php +// modules/blog/routes/api/posts.php +use Illuminate\Support\Facades\Route; +use Modules\Blog\Http\Controllers\PostController; + +Route::apiResource('posts', PostController::class); // /api/posts +``` + +```bash +php artisan migrate +``` + +The module now looks like this, and nothing in it depends on this package: + +```text +modules/blog/ +├── composer.json app/blog, namespace Modules\Blog +├── database/ +│ ├── factories/PostFactory.php +│ ├── migrations/2026_09_24_120000_create_posts_table.php +│ └── seeders/PostSeeder.php +├── routes/api/posts.php loaded with the "api" prefix and middleware +├── src/ +│ ├── Http/Controllers/PostController.php +│ └── Models/Post.php +└── tests/Feature/PostTest.php +``` + +Next steps: add the module tests to `phpunit.xml` ([Tests](#tests)), call `Modules::seeders()` from the +`DatabaseSeeder` ([Seeders](#seeders)), and cache the modules on deploy with `php artisan optimize`. + ## Modules A module is a directory in `modules/` with a `composer.json`: @@ -81,9 +157,11 @@ A module is a directory in `modules/` with a `composer.json`: translations and Blade components: `view('blog::index')`, `__('blog::messages.welcome')`, ``. - The first `autoload.psr-4` entry is the root namespace of the module. - Every module must have a valid `composer.json` with a unique package name and namespace. An invalid - module is an error while the application boots, for HTTP requests too: `module:doctor` and the tests - catch it before a deploy. -- The module is required by the application like any other package: + module is an error while the application boots, for HTTP requests too. Artisan commands and the tests + fail the same way, so it is caught before a deploy. + +The application requires every module like any other package. You do not have to write this by hand: +`module:make` and `module:sync` maintain it. ```json { @@ -93,24 +171,62 @@ A module is a directory in `modules/` with a `composer.json`: "repositories": [ {"type": "path", "url": "modules/*", "options": {"symlink": true}}, {"type": "composer", "url": "https://repo.packagist.org", "exclude": ["app/*"]} - ] + ], + "autoload-dev": { + "psr-4": { + "Modules\\Blog\\Tests\\": "modules/blog/tests/" + } + } } ``` -You do not have to write this by hand: `module:make` and `module:sync` maintain it. - - `*@dev` accepts the `dev-*` version of a path package with `"minimum-stability": "stable"`. - The Packagist repository excludes the vendor of the modules, so Composer fails instead of installing a public package when a module directory is missing (another branch, a partial checkout). Composer uses this repository in place of the default one. Pick a vendor that you do not publish public packages under. -- Packagist mirrors defined in the project get the exclusion too: a mirror under the `packagist.org` key of - `"repositories": {...}`, and a list entry named `packagist` or `packagist.org`, which - `composer config repo.packagist composer ` writes. Exclude the vendor by hand in any other repository - that proxies Packagist. -- A Packagist mirror of the global Composer config (`~/.composer/config.json`) is not edited: define it in - the project `composer.json` instead. The Packagist entry of the project replaces a global mirror under the - `packagist.org` key; a global mirror that Composer 2.10 wrote as a named list entry is still used, without - the exclusion. +- Packagist mirrors defined in the project get the exclusion too: a repository with a `packagist.org` URL, + a mirror under the `packagist` or `packagist.org` key of `"repositories": {...}`, and a list entry named + `packagist` or `packagist.org`, which `composer config repo.packagist composer ` writes. Exclude the + vendor by hand in any other repository that proxies Packagist. +- Mirrors of the global Composer config (`~/.composer/config.json`) are not edited, so define your mirror in + the project `composer.json`. A global mirror under the `packagist.org` key is replaced by the Packagist + entry of the project and is no longer used. A global mirror that Composer 2.10 wrote as a named list entry + is still used, without the exclusion. + +### Dependencies between modules + +A module declares what it uses in its `composer.json`, other modules included: + +```json +{ + "name": "app/blog", + "require": { + "app/users": "*" + } +} +``` + +- Use `*`, not `*@dev`: Composer reads stability flags only from the application `composer.json`, which + already requires every module with `*@dev`. +- After a change of `require`, run `php artisan module:sync`: it runs `composer update` for the module. +- `module:delete` fails while another module requires the module, and nothing is deleted. With `--no-update` + Composer does not run, so this is not checked. + +### Finding modules + +The `Modules` facade finds modules by their directory name: + +```php +use Laraneat\Modules\Facades\Modules; + +Modules::all(); // array of Module, keyed by name +Modules::find('blog'); // Module or null +Modules::get('blog'); // Module, or throws ModuleNotFound +``` + +`Laraneat\Modules\Module` is a read-only object with the `name` (`shop-order`), `package` (`app/shop-order`), +`namespace` (`Modules\ShopOrder`), `path` and `sourcePath` (the absolute directory of the root namespace) +properties. ### Creating a module @@ -147,12 +263,12 @@ cp -R stubs/module/default stubs/module/api `{{ variable|filter|filter }}`. - Variables: - | Variable | Example value | - |-------------|--------------------------| - | `name` | `article-category` | - | `namespace` | `Modules\ArticleCategory` | - | `package` | `app/article-category` | - | `vendor` | `app` | + | Variable | Example value | + |-------------|-------------------------------------------| + | `name` | `article-category` | + | `namespace` | `Modules\ArticleCategory` | + | `package` | `app/article-category` | + | `vendor` | `app` | | `date` | `2026_09_23_120000` (for migration names) | - Filters: `studly`, `camel`, `snake`, `kebab`, `plural`, `singular`, `lower`, `upper`, `title`, and `json` @@ -179,15 +295,16 @@ The package loads everything it finds in a module. A missing directory is skippe | `config/*.php` | config keyed by the file name: `config/blog.php` is `config('blog')`. The application config wins. | | `lang/` | translations: `__('blog::messages.welcome')`, plus JSON files | | `resources/views/` | views: `view('blog::posts.index')` | -| `src/View/Components/` | Blade components: `` | +| `src/View/Components/` | Blade components: ``, or the anonymous component `resources/views/components/alert.blade.php` | | `database/migrations/` | migrations of `php artisan migrate` (console only) | | `database/factories/` | factories of the module models (console only, see below) | | `database/seeders/` | seeders of `Modules::seeders()` | | `routes/api/`, `routes/web/` | [route groups](#routes) | | `src/Console/Commands/` | Artisan commands (console only) | -`src/` is the directory of the root namespace of the module: `View/Components` and `Console/Commands` are -looked up there. +`src/` stands for the directory of the root namespace (the first `autoload.psr-4` entry): `View/Components` +and `Console/Commands` are looked up there, or in the [namespaces](#namespaces) of `make:component` and +`make:command`. A module config file can extend any key, `config/app.php` included. The merge is shallow: the top-level keys of the application config replace those of the module. @@ -215,12 +332,16 @@ final class Post extends Model } ``` -### Commands +### Module commands Classes in the `make:command` namespace of a module (`src/Console/Commands`, including subdirectories) are -registered as Artisan commands. Abstract classes and other classes are skipped. Commands with the -`#[AsCommand]` attribute are loaded lazily. Like migrations, module commands are registered only in the -console, so `Artisan::call()` can not run them during an HTTP request. +registered as Artisan commands. Abstract classes and classes that are not commands are skipped. Commands +with the `#[AsCommand]` attribute are loaded lazily. Like migrations, module commands are registered only +in the console, so `Artisan::call()` cannot run them during an HTTP request. + +### Tinker + +Tinker aliases the module classes like the application classes: `Post::first()` works without the namespace. ### Policies and events @@ -287,7 +408,7 @@ Nested commands run in the same module: `make:model Post --module=blog -mfs` cre migration, the factory and the seeder in `blog`. `--model` and `--parent` name models of the module: `--model=Post` is `Modules\Blog\Models\Post`, or a class of -the `make:model` namespace (see below). As in the application, they can not reference a model of another namespace. +the `make:model` namespace (see below). As in the application, they cannot reference a model of another namespace. ### Namespaces @@ -309,11 +430,11 @@ requests there, and the controller imports the requests from `UI\API\Requests`. `make:test`, `Modules\Blog\Database\Factories` for `make:factory`, `Modules\Blog\Database\Seeders` for `make:seeder` and `Modules\Blog` for the others. - A name that starts with the module namespace is used as it is: `make:action "Modules\Blog\Domain\Publish"`. -- The `make:model` namespace also applies to `--model` and `--parent` of the Laravel generators. The factory of `make:model -f` goes - where the factory resolver looks for it, whatever the `make:factory` namespace. +- The `make:model` namespace also applies to `--model` and `--parent` of the Laravel generators. The factory + of `make:model -f` goes where the factory resolver looks for it, whatever the `make:factory` namespace. - `Modules::seeders()` reads only direct subdirectories of `database/seeders`, so a `make:seeder` namespace has at most one segment. -- The `make:command` namespace is also where [module commands](#commands) are discovered, and the +- The `make:command` namespace is also where [module commands](#module-commands) are discovered, and the `make:component` namespace is where `` components are looked up. The stubs of the generators are the Laravel ones: customize them with `php artisan stub:publish`. @@ -385,19 +506,26 @@ With Pest, also extend the test case in `tests/Pest.php`: pest()->extend(Tests\TestCase::class)->in('Feature', '../modules/*/tests/Feature'); ``` +Run the tests of one module by its path: + +```bash +php artisan test modules/blog/tests +``` + ## Commands | Command | Description | |---------------------------------|-------------| | `module:make ` | Create a module. `--preset=` picks the template, `--no-update` skips `composer update`. | | `module:sync` | Add every module to the `composer.json` of the application and run `composer update` for the modules that changed. `--no-update` only prints the command. | -| `module:delete ` | Run `composer remove` for the module, then delete its directory. Asks for confirmation; `--force` skips it. With `--no-update`, it edits `composer.json` instead of running Composer, deletes the directory and prints the `composer update` command to run. A symlinked module directory is never deleted. | +| `module:delete ` | Remove the `autoload-dev` entries of the module, run `composer remove` for it, then delete its directory. Asks for confirmation; `--force` skips it and is required in non-interactive mode. With `--no-update`, it edits `composer.json` instead of running Composer, deletes the directory and prints the `composer update` command to run. A symlinked module directory is never deleted. | | `module:doctor` | Check the modules and how they are installed. Read-only; fails when it finds errors. | | `module:list` | List the modules. With `-v`, also what is loaded from each of them. | | `module:cache` | Cache the module manifest. Part of `php artisan optimize`. | | `module:clear` | Remove the module manifest cache. Part of `php artisan optimize:clear`. | Commands and `--module` take the module directory name (`blog`), not the package name (`app/blog`). +Composer runs in the application directory: `composer.phar` there if it exists, otherwise `composer` from `PATH`. `module:doctor` checks: @@ -409,7 +537,7 @@ Commands and `--module` take the module directory name (`blog`), not the package - that the `extra.laravel.providers` classes exist; - route files outside of the configured route groups; - unknown keys in `config/modules.php`, an outdated manifest cache, and a modules path that - `octane:start --watch` can not watch. + `octane:start --watch` cannot watch. Most problems are fixed by `php artisan module:sync`. @@ -419,7 +547,7 @@ The `about` command shows the number of modules, their path and whether the mani ```php return [ - // The directory of the modules. Every subdirectory with a composer.json is a module. + // The directory of the modules. Every direct subdirectory with a composer.json is a module. 'path' => base_path('modules'), // The namespace prefix and the Composer vendor of new modules. @@ -440,7 +568,7 @@ return [ ## Performance and caching The package builds a manifest of the modules: their packages, namespaces and the files it loads. The -provider only reads the manifest, it never scans the modules while it boots. +provider loads everything from the manifest and does not look for module files by itself. - `php artisan optimize` (or `module:cache`) writes the manifest to `bootstrap/cache/modules.php`, next to the config and route caches. A cached application loads it with one `require`. Set `MODULES_CACHE` to @@ -449,8 +577,8 @@ provider only reads the manifest, it never scans the modules while it boots. - Without the cache file, the manifest is built once per process, so it is never stale. The cache is written only by `optimize` and `module:cache`: a deploy that skips them scans the modules in every process. - The cached manifest lists the files it loads. After `php artisan optimize` on a development machine, new - route, config, seeder and command files are ignored until `php artisan optimize:clear`, and deleted ones - are skipped. Only a cached manifest is checked for deleted files: a built one lists the files on disk. + modules and new route, config, seeder and command files are ignored until `php artisan optimize:clear` + (or `module:clear`); deleted files are skipped. - `module:make`, `module:sync` and `module:delete` rebuild the cache file if it exists. - The config and route caches include the module config and routes, so the package skips them when they are cached. From abe5770e7d26aa4d339cf745a22f44953b959246 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Thu, 24 Sep 2026 18:24:53 +0300 Subject: [PATCH 34/38] Join generator namespaces without empty segments and keep kebab case module names An empty namespace in the generators config gave class names with a double backslash: Blade components, the form requests of controllers and component views broke. An empty make:command namespace is refused, since commands are discovered in it. Module names already in kebab case are kept, and acronyms stay one word. --- src/Generators/ComponentMakeCommand.php | 3 ++- src/Generators/ControllerMakeCommand.php | 3 ++- src/Generators/ModuleOption.php | 3 ++- src/ModulesServiceProvider.php | 5 +++++ src/Registrars/ResourceRegistrar.php | 7 +++---- src/Scaffold/Names.php | 18 ++++++++++++++++-- tests/Feature/Conventions/ViewsTest.php | 24 ++++++++++++++++++++++++ tests/Feature/ProviderTest.php | 6 ++++++ tests/Generators/GeneratorsTest.php | 14 ++++++++++++++ tests/Unit/Scaffold/NamesTest.php | 15 +++++++++++++++ 10 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src/Generators/ComponentMakeCommand.php b/src/Generators/ComponentMakeCommand.php index 2395fed..0c129c9 100644 --- a/src/Generators/ComponentMakeCommand.php +++ b/src/Generators/ComponentMakeCommand.php @@ -6,6 +6,7 @@ use Illuminate\Foundation\Console\ComponentMakeCommand as BaseComponentMakeCommand; use Illuminate\Support\Facades\Config; +use Laraneat\Modules\Scaffold\Names; use Override; /** @@ -30,7 +31,7 @@ protected function getView() return $this->namespacedView(); } - $components = $module->namespace.'\\'.trim(Config::string('modules.generators.make:component', 'View\\Components'), '\\').'\\'; + $components = Names::qualify($module->namespace, Config::string('modules.generators.make:component', 'View\\Components')).'\\'; $class = str_replace('/', '\\', ltrim($name, '\\/')); if (! str_starts_with($class, $components)) { diff --git a/src/Generators/ControllerMakeCommand.php b/src/Generators/ControllerMakeCommand.php index 36a39db..d866c1d 100644 --- a/src/Generators/ControllerMakeCommand.php +++ b/src/Generators/ControllerMakeCommand.php @@ -6,6 +6,7 @@ use Illuminate\Routing\Console\ControllerMakeCommand as BaseControllerMakeCommand; use Illuminate\Support\Facades\Config; +use Laraneat\Modules\Scaffold\Names; use Override; /** @@ -39,7 +40,7 @@ protected function buildFormRequestReplacements(array $replace, $modelClass) return $replace; } - $namespace = $module->namespace.'\\'.trim(Config::string('modules.generators.make:request', 'Http\\Requests'), '\\').'\\'; + $namespace = Names::qualify($module->namespace, Config::string('modules.generators.make:request', 'Http\\Requests')).'\\'; foreach (self::REQUEST_REPLACEMENTS as $key) { if (is_string($replace[$key] ?? null)) { diff --git a/src/Generators/ModuleOption.php b/src/Generators/ModuleOption.php index 091963e..3fed740 100644 --- a/src/Generators/ModuleOption.php +++ b/src/Generators/ModuleOption.php @@ -24,6 +24,7 @@ use Illuminate\Support\Facades\Config; use Laraneat\Modules\Module; use Laraneat\Modules\ModuleRepository; +use Laraneat\Modules\Scaffold\Names; use Stringable; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -162,7 +163,7 @@ private static function qualifyInput(mixed $class, Module $module, string $root, $class = trim(str_replace('/', '\\', (string) $class), '\\'); if ($class !== '' && ! str_starts_with($class, $module->namespace.'\\')) { - $set(implode('\\', array_filter([rtrim($root, '\\'), trim($namespace, '\\'), $class], static fn (string $part): bool => $part !== ''))); + $set(Names::qualify($root, $namespace, $class)); } } } diff --git a/src/ModulesServiceProvider.php b/src/ModulesServiceProvider.php index 883dd2b..461b63a 100644 --- a/src/ModulesServiceProvider.php +++ b/src/ModulesServiceProvider.php @@ -128,6 +128,11 @@ private static function manifestBuilder(Application $app): ManifestBuilder } } + // An empty namespace would make every class of a module a candidate command. + if (trim($generators['make:command'] ?? 'Console\\Commands', '\\') === '') { + throw InvalidConfiguration::because('the namespace of the [make:command] generator can not be empty: module commands are discovered in it.'); + } + $path = Config::string('modules.path'); return new ManifestBuilder( diff --git a/src/Registrars/ResourceRegistrar.php b/src/Registrars/ResourceRegistrar.php index 9b3154a..0df1670 100644 --- a/src/Registrars/ResourceRegistrar.php +++ b/src/Registrars/ResourceRegistrar.php @@ -13,6 +13,7 @@ use Illuminate\View\Compilers\BladeCompiler; use Laraneat\Modules\Exceptions\InvalidModule; use Laraneat\Modules\Manifest\ManifestBuilder; +use Laraneat\Modules\Scaffold\Names; /** * Config, translations, views, Blade components and migrations of the modules. @@ -107,11 +108,9 @@ private function registerCallbacks(): void if ($this->manifest !== []) { // renders the Alert class of the components namespace, or the "blog::components.alert" view. - $namespace = trim($this->componentsNamespace, '\\'); - - $this->afterResolving('blade.compiler', function (BladeCompiler $blade) use ($namespace): void { + $this->afterResolving('blade.compiler', function (BladeCompiler $blade): void { foreach ($this->manifest as $name => $module) { - $blade->componentNamespace($module['namespace'].'\\'.$namespace, $name); + $blade->componentNamespace(Names::qualify($module['namespace'], $this->componentsNamespace), $name); } }); } diff --git a/src/Scaffold/Names.php b/src/Scaffold/Names.php index cbc3f9e..6c8dfce 100644 --- a/src/Scaffold/Names.php +++ b/src/Scaffold/Names.php @@ -24,11 +24,14 @@ final class Names private const string PHP_NAMESPACE = '{^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)*$}D'; /** - * Normalize a module name to kebab case: "ShopOrder", "shop_order" and "shop-order" are "shop-order". + * Normalize a module name to kebab case: "ShopOrder", "shop_order" and "shop-order" are "shop-order", + * "HTTPClient" is "http-client". A name in kebab case is kept as it is: "shop-order-2". */ public static function module(string $name): string { - $module = Str::kebab(Str::studly(trim($name))); + $name = trim($name); + $words = preg_split('/[-_\s]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/', $name, -1, PREG_SPLIT_NO_EMPTY) ?: []; + $module = strtolower(implode('-', $words)); if (preg_match(self::MODULE, $module) !== 1) { throw InvalidName::of('module name', $name, 'use latin letters, digits and dashes, starting with a letter.'); @@ -83,4 +86,15 @@ public static function isNamespace(string $namespace): bool { return preg_match(self::PHP_NAMESPACE, $namespace) === 1; } + + /** + * Join namespace segments, skipping empty ones: "Modules\Blog", "" and "Post" give "Modules\Blog\Post". + */ + public static function qualify(string ...$segments): string + { + return implode('\\', array_filter( + array_map(static fn (string $segment): string => trim($segment, '\\'), $segments), + static fn (string $segment): bool => $segment !== '', + )); + } } diff --git a/tests/Feature/Conventions/ViewsTest.php b/tests/Feature/Conventions/ViewsTest.php index 658633a..9c23efa 100644 --- a/tests/Feature/Conventions/ViewsTest.php +++ b/tests/Feature/Conventions/ViewsTest.php @@ -39,6 +39,30 @@ public function render(): string expect(Blade::render(' '))->toBe('class badge anonymous card'); }); +it('registers the Blade components in the module root with an empty make:component namespace', function () { + $this->files([ + 'config/modules.php' => ' ["make:component" => ""]];', + 'modules/blog/src/Badge.php' => <<<'PHP' + reboot(); + + expect(Blade::render(''))->toBe('root badge'); +}); + it('registers the Blade components in the namespace of make:component', function () { $this->files([ 'config/modules.php' => ' ["make:component" => "\\\\UI\\\\Components\\\\"]];', diff --git a/tests/Feature/ProviderTest.php b/tests/Feature/ProviderTest.php index af9cf03..83b78a7 100644 --- a/tests/Feature/ProviderTest.php +++ b/tests/Feature/ProviderTest.php @@ -115,6 +115,12 @@ expect(fn () => $this->reboot())->toThrow(InvalidConfiguration::class, 'the namespace of the [make:controller] generator must be a string.'); }); +it('refuses an empty make:command namespace', function (string $namespace) { + $this->files(['config/modules.php' => ' ["make:command" => '.var_export($namespace, true).']];']); + + expect(fn () => $this->reboot())->toThrow(InvalidConfiguration::class, 'the namespace of the [make:command] generator can not be empty'); +})->with(['', '\\']); + it('resolves a relative modules path against the application', function () { $this->files(['config/modules.php' => ' "modules"];']); chdir(sys_get_temp_dir()); diff --git a/tests/Generators/GeneratorsTest.php b/tests/Generators/GeneratorsTest.php index 9400c67..f0b2a35 100644 --- a/tests/Generators/GeneratorsTest.php +++ b/tests/Generators/GeneratorsTest.php @@ -138,6 +138,20 @@ expect($files['modules/blog/src/RackPolicy.php'])->toContain('namespace Modules\\Blog;', 'use Modules\\Blog\\Rack;'); }); +it('imports form requests and names component views in the module root with an empty namespace', function () { + $this->files(['config/modules.php' => ' ["make:request" => "", "make:component" => ""]];']); + $this->reboot(); + + $controller = $this->generate('make:controller PostController --model=Post --requests --module=blog'); + $component = $this->generate('make:component Forms/Input --module=blog'); + + expect($controller['modules/blog/src/Http/Controllers/PostController.php']) + ->toContain("use Modules\\Blog\\StorePostRequest;\nuse Modules\\Blog\\UpdatePostRequest;") + ->and($controller)->toHaveKeys(['modules/blog/src/StorePostRequest.php', 'modules/blog/src/UpdatePostRequest.php']) + ->and($component['modules/blog/src/Forms/Input.php'])->toContain("view('blog::components.forms.input')") + ->and($component)->toHaveKey('modules/blog/resources/views/components/forms/input.blade.php'); +}); + it('leaves the --model option of other generators alone', function () { $this->files(['config/modules.php' => ' ["make:model" => "Domain\\\\Models"]];']); $this->reboot(); diff --git a/tests/Unit/Scaffold/NamesTest.php b/tests/Unit/Scaffold/NamesTest.php index 4df5f50..f1d4132 100644 --- a/tests/Unit/Scaffold/NamesTest.php +++ b/tests/Unit/Scaffold/NamesTest.php @@ -17,6 +17,11 @@ ['shop order', 'shop-order'], [' blog ', 'blog'], ['api-v2', 'api-v2'], + ['shop-order-2', 'shop-order-2'], + ['ShopOrder2', 'shop-order2'], + ['API', 'api'], + ['HTTPClient', 'http-client'], + ['Shop-Order', 'shop-order'], ['blog2', 'blog2'], ['list', 'list'], 'leading dash' => ['-blog', 'blog'], @@ -97,6 +102,16 @@ expect(fn () => Names::namespace($prefix, 'blog'))->toThrow(InvalidName::class, 'Invalid namespace'); })->with(['', 'App\\\\Modules', '1Modules', 'App-Modules', 'App\\Modules;']); +it('joins namespace segments without the empty ones', function (array $segments, string $namespace) { + expect(Names::qualify(...$segments))->toBe($namespace); +})->with([ + [['Modules\\Blog', 'Http\\Requests'], 'Modules\\Blog\\Http\\Requests'], + [['Modules\\Blog\\', '\\UI\\Components\\', 'Alert'], 'Modules\\Blog\\UI\\Components\\Alert'], + [['Modules\\Blog', '', 'Post'], 'Modules\\Blog\\Post'], + [['Modules\\Blog', '\\'], 'Modules\\Blog'], + [['Modules\\Blog', '0'], 'Modules\\Blog\\0'], +]); + it('validates namespaces', function (string $namespace, bool $valid) { expect(Names::isNamespace($namespace))->toBe($valid); })->with([ From 15dd57c6f2c731e297d37ae51fc1f31f48dbbb26 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Thu, 24 Sep 2026 18:24:53 +0300 Subject: [PATCH 35/38] Discover commands and seeders only in files that declare their class Composer includes a file each time a class is looked up in it, so a file of functions in the commands or seeders directory was included again, and failed, on every check. --- src/Manifest/ManifestBuilder.php | 25 ++++++--- .../blog/src/Console/Commands/helpers.php | 3 +- tests/Unit/Manifest/ManifestBuilderTest.php | 52 +++++++++++++++---- 3 files changed, 63 insertions(+), 17 deletions(-) diff --git a/src/Manifest/ManifestBuilder.php b/src/Manifest/ManifestBuilder.php index c931807..d3a4281 100644 --- a/src/Manifest/ManifestBuilder.php +++ b/src/Manifest/ManifestBuilder.php @@ -209,12 +209,12 @@ private function seeders(string $path, string $namespace): array [$files, $directories] = $this->scan($directory); $seeders = []; - foreach ($this->classes($files) as $class) { + foreach ($this->classes($directory, $files) as $class) { $seeders[''][] = $namespace.$class; } foreach (array_filter($directories, $this->isIdentifier(...)) as $subdirectory) { - foreach ($this->classes($this->scan($directory.'/'.$subdirectory)[0]) as $class) { + foreach ($this->classes($directory.'/'.$subdirectory, $this->scan($directory.'/'.$subdirectory)[0]) as $class) { $seeders[$subdirectory][] = $namespace.$subdirectory.'\\'.$class; } } @@ -240,7 +240,7 @@ private function commands(string $path, string $source, string $namespace): arra private function classesIn(string $directory, string $namespace): array { [$files, $directories] = $this->scan($directory); - $classes = array_map(static fn (string $class): string => $namespace.$class, $this->classes($files)); + $classes = array_map(static fn (string $class): string => $namespace.$class, $this->classes($directory, $files)); foreach (array_filter($directories, $this->isIdentifier(...)) as $subdirectory) { array_push($classes, ...$this->classesIn($directory.'/'.$subdirectory, $namespace.$subdirectory.'\\')); @@ -250,19 +250,32 @@ private function classesIn(string $directory, string $namespace): array } /** - * Class names of PHP files. + * Class names of the PHP files that declare a class of their name. Files without one are left out: + * Composer includes a file every time a class is looked up in it, so a file of functions would be + * included again, and fail, each time the provider checks it for a command or a seeder. * * @param list $files * @return list */ - private function classes(array $files): array + private function classes(string $directory, array $files): array { return array_values(array_filter( array_map(static fn (string $file): string => substr($file, 0, -4), $files), - $this->isIdentifier(...), + fn (string $class): bool => $this->isIdentifier($class) && $this->declares($directory.'/'.$class.'.php', $class), )); } + /** + * Whether a line of the file declares a class, an interface, a trait or an enum of the name. + */ + private function declares(string $file, string $class): bool + { + return preg_match( + '/^[ \t]*(?:#\[.*\][ \t]*)?(?:(?:abstract|final|readonly)[ \t]+)*(?:class|interface|trait|enum)[ \t]+'.$class.'\b/mi', + (string) file_get_contents($file), + ) === 1; + } + /** * PHP files and subdirectories of a directory, sorted, without hidden entries. * diff --git a/tests/Fixtures/app/modules/blog/src/Console/Commands/helpers.php b/tests/Fixtures/app/modules/blog/src/Console/Commands/helpers.php index ed5ec41..28c81b9 100644 --- a/tests/Fixtures/app/modules/blog/src/Console/Commands/helpers.php +++ b/tests/Fixtures/app/modules/blog/src/Console/Commands/helpers.php @@ -1,3 +1,4 @@ 'routes/ return new ManifestBuilder($modulesPath, $routes, $commands); } +function phpClass(string $class, string $declaration = 'class'): string +{ + return " 'src/'], array $extra = []): string { return json_encode(['name' => $name, 'autoload' => ['psr-4' => $psr4], ...$extra], JSON_THROW_ON_ERROR); @@ -54,7 +59,6 @@ function moduleComposerJson(string $name, array $psr4 = ['Modules\\Demo\\' => 's 'commands' => [ 'Modules\\Blog\\Console\\Commands\\BlogCommand', 'Modules\\Blog\\Console\\Commands\\PublishPostsCommand', - 'Modules\\Blog\\Console\\Commands\\helpers', 'Modules\\Blog\\Console\\Commands\\Nested\\ArchivePostsCommand', ], ], @@ -242,10 +246,10 @@ function moduleComposerJson(string $name, array $psr4 = ['Modules\\Demo\\' => 's it('collects seeders of database/seeders and its direct subdirectories only', function () { $modules = $this->files([ 'demo/composer.json' => moduleComposerJson('app/demo'), - 'demo/database/seeders/RootSeeder.php' => ' ' ' ' phpClass('RootSeeder'), + 'demo/database/seeders/Deployment/DeploySeeder_1.php' => phpClass('DeploySeeder_1'), + 'demo/database/seeders/Deployment/Deeper/IgnoredSeeder.php' => phpClass('IgnoredSeeder'), + 'demo/database/seeders/not-a-namespace/IgnoredSeeder.php' => phpClass('IgnoredSeeder'), 'demo/database/seeders/not-a-class.php' => ' '', ]); @@ -259,10 +263,10 @@ function moduleComposerJson(string $name, array $psr4 = ['Modules\\Demo\\' => 's it('collects commands recursively from the make:command namespace', function (string $namespace, string $directory) { $modules = $this->files([ 'demo/composer.json' => moduleComposerJson('app/demo', ['Modules\\Demo\\' => 'app/']), - "demo/app/{$directory}/SendCommand.php" => ' ' ' ' phpClass('SendCommand'), + "demo/app/{$directory}/Deep/Er/CleanCommand.php" => phpClass('CleanCommand'), + "demo/app/{$directory}/not-a-namespace/IgnoredCommand.php" => phpClass('IgnoredCommand'), + 'demo/app/Other/IgnoredCommand.php' => phpClass('IgnoredCommand'), ]); expect(manifestBuilder($modules, [], $namespace)->build()['demo']['commands'])->toBe([ @@ -277,12 +281,40 @@ function moduleComposerJson(string $name, array $psr4 = ['Modules\\Demo\\' => 's it('collects commands of a module whose root namespace is its directory', function () { $modules = $this->files([ 'demo/composer.json' => moduleComposerJson('app/demo', ['Modules\\Demo\\' => '']), - 'demo/Console/Commands/SendCommand.php' => ' phpClass('SendCommand'), ]); expect(manifestBuilder($modules)->build()['demo']['commands'])->toBe(['Modules\\Demo\\Console\\Commands\\SendCommand']); }); +it('leaves out files that do not declare a class of their name', function () { + $modules = $this->files([ + 'demo/composer.json' => moduleComposerJson('app/demo'), + 'demo/src/Console/Commands/AbstractCommand.php' => phpClass('AbstractCommand', 'abstract class'), + 'demo/src/Console/Commands/AttributeCommand.php' => " phpClass('CommandContract', 'interface'), + 'demo/src/Console/Commands/CommandTrait.php' => phpClass('CommandTrait', 'trait'), + 'demo/src/Console/Commands/IndentedCommand.php' => " phpClass('Status', 'enum'), + 'demo/src/Console/Commands/helpers.php' => " " phpClass('OtherCommand'), + 'demo/src/Console/Commands/Prefix.php' => phpClass('PrefixCommand'), + 'demo/database/seeders/helpers.php' => "build()['demo']; + + expect($module['commands'])->toBe([ + 'Modules\\Demo\\Console\\Commands\\AbstractCommand', + 'Modules\\Demo\\Console\\Commands\\AttributeCommand', + 'Modules\\Demo\\Console\\Commands\\CommandContract', + 'Modules\\Demo\\Console\\Commands\\CommandTrait', + 'Modules\\Demo\\Console\\Commands\\IndentedCommand', + 'Modules\\Demo\\Console\\Commands\\Status', + ])->and($module['seeders'])->toBe([]); +}); + it('has a key that changes with the settings', function () { $key = manifestBuilder('/app/modules')->key(); From c58fcd1ee469209d3f3026ca83a9994e46b76c46 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Thu, 24 Sep 2026 18:24:54 +0300 Subject: [PATCH 36/38] Accept array autoload mappings of factories and seeders in module:doctor --- src/Commands/DoctorCommand.php | 7 +++++-- tests/Feature/Commands/DoctorCommandTest.php | 2 +- tests/TestCase.php | 12 +++++++----- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/Commands/DoctorCommand.php b/src/Commands/DoctorCommand.php index 25f1d6c..df7ba43 100644 --- a/src/Commands/DoctorCommand.php +++ b/src/Commands/DoctorCommand.php @@ -140,9 +140,12 @@ private function checkModule(Module $module, array $manifest, ComposerJson $root foreach (['database/factories' => 'Database\\Factories', 'database/seeders' => 'Database\\Seeders'] as $directory => $namespace) { $namespace = $module->namespace.'\\'.$namespace.'\\'; - $mapped = $autoload[$namespace] ?? null; + $mapped = array_map( + static fn (string $path): string => trim((string) preg_replace('{^\./}', '', $path), '/'), + array_filter((array) ($autoload[$namespace] ?? []), is_string(...)), + ); - if (is_dir($module->path.'/'.$directory) && (! is_string($mapped) || trim((string) preg_replace('{^\./}', '', $mapped), '/') !== $directory)) { + if (is_dir($module->path.'/'.$directory) && ! in_array($directory, $mapped, true)) { $problems[] = [true, "\"autoload.psr-4\" must map [{$namespace}] to [{$directory}/]."]; } } diff --git a/tests/Feature/Commands/DoctorCommandTest.php b/tests/Feature/Commands/DoctorCommandTest.php index 9735cde..4b21ebd 100644 --- a/tests/Feature/Commands/DoctorCommandTest.php +++ b/tests/Feature/Commands/DoctorCommandTest.php @@ -152,7 +152,7 @@ function removeLink(string $path): void it('accepts equivalent autoload paths and skips directories a module does not have', function () { // shop-order has no database/factories directory. $composerJson = $this->readJson('modules/shop-order/composer.json'); - $composerJson['autoload']['psr-4'] = ['Modules\\ShopOrder\\' => './src', 'Modules\\ShopOrder\\Database\\Seeders\\' => './database/seeders']; + $composerJson['autoload']['psr-4'] = ['Modules\\ShopOrder\\' => './src', 'Modules\\ShopOrder\\Database\\Seeders\\' => ['lib/', './database/seeders']]; $composerJson['extra'] = ['laravel' => ['providers' => ['Modules\\ShopOrder\\Providers\\MissingServiceProvider']]]; $this->files(['modules/shop-order/composer.json' => json_encode($composerJson)]); $this->installModules(); diff --git a/tests/TestCase.php b/tests/TestCase.php index f18127b..311058d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -224,13 +224,15 @@ private function autoloadModules(string $class): void $composer = json_decode((string) file_get_contents($composerJson), true); $psr4 = [...(array) ($composer['autoload']['psr-4'] ?? []), ...(array) ($composer['autoload-dev']['psr-4'] ?? [])]; - foreach ($psr4 as $namespace => $directory) { - $file = dirname($composerJson).'/'.$directory.'/'.str_replace('\\', '/', substr($class, strlen($namespace))).'.php'; + foreach ($psr4 as $namespace => $directories) { + foreach ((array) $directories as $directory) { + $file = dirname($composerJson).'/'.$directory.'/'.str_replace('\\', '/', substr($class, strlen($namespace))).'.php'; - if (str_starts_with($class, $namespace) && is_file($file)) { - require $file; + if (str_starts_with($class, $namespace) && is_file($file)) { + require $file; - return; + return; + } } } } From 069365975d13089c92ab3273c8aab44de4f0eb43 Mon Sep 17 00:00:00 2001 From: Jackardios Date: Thu, 24 Sep 2026 18:24:54 +0300 Subject: [PATCH 37/38] Complete the upgrade guide and the changelog for 3.0 Require 2.1 as the starting point, correct the route and exception notes, and list the changes the changelog missed. --- CHANGELOG.md | 33 ++++++++++++++++++++++++++------- README.md | 13 +++++++++---- UPGRADE.md | 44 ++++++++++++++++++++++++++++++++------------ 3 files changed, 67 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81b7798..56a680a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,18 +10,23 @@ Version 3 is a rewrite. See [UPGRADE.md](UPGRADE.md) for the upgrade from 2.x. - `--module` for every generator: the Laravel `make:*` commands, `make:migration`, the table migration generators and the generators of other packages that extend `GeneratorCommand` (`make:action`, `make:data`, ...). -- Module resources are loaded by convention: config, translations, views, Blade components, migrations, - factories, seeders, routes and commands. Modules no longer need their own service provider. +- Module resources are loaded by convention: config, translations (JSON included), views, class and anonymous + Blade components, migrations, factories, routes and commands. Modules no longer need their own service + provider. +- Tinker aliases the module classes. - Route groups in `config/modules.php`; nested route directories are appended to the group prefix. - The `generators` config maps generator commands to namespaces inside the modules. - `Modules::seeders()` returns the seeders of all modules, in the order of their `_N` suffix. - Module templates in `stubs/module/` with `{{ variable|filter }}` placeholders in paths and contents. -- `module:doctor` checks the modules and how they are installed. +- `module:doctor` checks the modules and how they are installed; `module:list -v` shows what each module loads. +- `Modules::all()`, `Modules::find()` and `Modules::get()` find modules by their directory name. +- The `modules-config` and `modules-stubs` publish tags. - `--no-update` for `module:make`, `module:sync` and `module:delete`: edit `composer.json` and print the Composer command instead of running it. - `module:sync` adds the `autoload-dev` of the module tests and updates only the modules that changed. -- Packagist mirrors defined in the project (under the `packagist.org` key, or named `packagist` in the list) - get the exclusion of the module vendor too. +- Packagist mirrors defined in the project get the exclusion of the module vendor too: repositories with a + `packagist.org` URL, repositories under the `packagist` or `packagist.org` key, and list entries named + `packagist` or `packagist.org`. - The module manifest is cached by `php artisan optimize` and cleared by `optimize:clear`; `MODULES_CACHE` changes the cache path. - The modules path is added to `octane.watch`. @@ -31,11 +36,20 @@ Version 3 is a rewrite. See [UPGRADE.md](UPGRADE.md) for the upgrade from 2.x. - Requires PHP 8.3 and Laravel 13.12. - The facade is `Laraneat\Modules\Facades\Modules`; modules are identified by their directory name. +- The four providers of `Laraneat\Modules\Providers` are replaced by `Laraneat\Modules\ModulesServiceProvider`, + and the config publish tag `config` by `modules-config`. - `Laraneat\Modules\Module` is a read-only value object with public properties. -- The views and translations namespace of a module is its directory name. +- The views and translations namespace of a module is its directory name. Every `config/*.php` file of a + module is merged, not only `config/.php`. +- Route files of a directory are loaded before its subdirectories. +- An invalid module `composer.json` fails the application boot, and two modules can not share a namespace. + 2.x skipped a module without a name. +- `module:make` requires new modules with `*@dev` and has no `--force` option. +- `ModuleNotFound` is created with `ModuleNotFound::named()`; every exception implements `ModulesException`. - `module:delete` deletes one module, found by its directory name, asks for confirmation and needs `--force` in non-interactive mode. -- The manifest cache is written only by `php artisan optimize` and `module:cache`, not automatically in production. +- The manifest cache is written only by `php artisan optimize` and `module:cache`, not automatically in + production, to `bootstrap/cache/modules.php` instead of `bootstrap/cache/laraneat-modules.php`. - `Laraneat\Modules\ModulesRepository` is renamed to `Laraneat\Modules\ModuleRepository`. - The `composer.vendor` config key is renamed to `vendor`. - Migrations are registered only in the console. @@ -45,6 +59,11 @@ Version 3 is a rewrite. See [UPGRADE.md](UPGRADE.md) for the upgrade from 2.x. - The `module:make:*` component generators and their stubs, `module:stub:publish` and the `custom_stubs` config. - `module:migrate` and the `module:migrate:*` commands: use `migrate` and the `migrate:*` commands. - `Laraneat\Modules\Support\ModuleServiceProvider`, `CanLoadRoutesFromDirectory` and `CanRunModuleSeeders`. +- The methods of `ModulesRepository` and the getters, macros and `toArray()` of `Module`: UPGRADE.md maps them + to 3.0. +- `Laraneat\Modules\Enums`, `Laraneat\Modules\Support\Generator`, `Support\Composer`, `Support\ComposerJsonFile`, + `Support\ModuleConfigWriter`, `Support\Migrations` and the 2.x exceptions except `ModuleNotFound`. +- `module:make --preset=plain|base|api` and `--entity`: module templates replace them. - `InteractsWithTestUser` and `WithJsonResponseHelpers`: UPGRADE.md has versions to copy into the application. - The `components`, `composer.author`, `user_model`, `create_permission` and `cache` config keys. - The `composer/composer` dependency. diff --git a/README.md b/README.md index 604b169..5bf88d7 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,8 @@ php artisan module:make blog --no-update 3. runs `composer update app/blog`. With `--no-update` it only prints the command, which is useful in CI or when an agent drives the work. -The name is normalized to kebab case (`ShopOrder` becomes `shop-order`, namespace `Modules\ShopOrder`). +The name is normalized to kebab case: `ShopOrder` becomes `shop-order` (namespace `Modules\ShopOrder`), `API` +becomes `api`. A name in kebab case is kept as it is. ### Module templates @@ -335,7 +336,8 @@ final class Post extends Model ### Module commands Classes in the `make:command` namespace of a module (`src/Console/Commands`, including subdirectories) are -registered as Artisan commands. Abstract classes and classes that are not commands are skipped. Commands +registered as Artisan commands. Abstract classes, classes that are not commands and files that do not declare +a class of their name are skipped. Commands with the `#[AsCommand]` attribute are loaded lazily. Like migrations, module commands are registered only in the console, so `Artisan::call()` cannot run them during an HTTP request. @@ -430,6 +432,8 @@ requests there, and the controller imports the requests from `UI\API\Requests`. `make:test`, `Modules\Blog\Database\Factories` for `make:factory`, `Modules\Blog\Database\Seeders` for `make:seeder` and `Modules\Blog` for the others. - A name that starts with the module namespace is used as it is: `make:action "Modules\Blog\Domain\Publish"`. +- An empty namespace is the root namespace of the module: `'make:policy' => ''` creates `Modules\Blog\PostPolicy`. + The `make:command` namespace can not be empty, because module commands are discovered in it. - The `make:model` namespace also applies to `--model` and `--parent` of the Laravel generators. The factory of `make:model -f` goes where the factory resolver looks for it, whatever the `make:factory` namespace. - `Modules::seeders()` reads only direct subdirectories of `database/seeders`, so a `make:seeder` namespace @@ -577,8 +581,9 @@ provider loads everything from the manifest and does not look for module files b - Without the cache file, the manifest is built once per process, so it is never stale. The cache is written only by `optimize` and `module:cache`: a deploy that skips them scans the modules in every process. - The cached manifest lists the files it loads. After `php artisan optimize` on a development machine, new - modules and new route, config, seeder and command files are ignored until `php artisan optimize:clear` - (or `module:clear`); deleted files are skipped. + modules, new `lang`, `resources/views` and `database/migrations` directories, and new route, config, seeder + and command files are ignored until `php artisan optimize:clear` (or `module:clear`); deleted files are + skipped. - `module:make`, `module:sync` and `module:delete` rebuild the cache file if it exists. - The config and route caches include the module config and routes, so the package skips them when they are cached. diff --git a/UPGRADE.md b/UPGRADE.md index d08855d..e2ffdd8 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -10,7 +10,8 @@ every step, and run the checks at the end. ### Before you start -- The application runs Laravel 13.12 or newer with the latest `laraneat/modules` 2.x, and PHP 8.3 or newer. +- The application runs Laravel 13.12 or newer and PHP 8.3 or newer, with `laraneat/modules` 2.1 or newer: the + first 2.x release that supports Laravel 13. The steps compare 3.0 with it. - The test suite passes. - Save the routes of the application to compare them later: @@ -59,7 +60,8 @@ loaded anymore. | `components` without a namespace (`config`, `lang`, `migration`, `view`) | fixed: `config`, `lang`, `database/migrations`, `resources/views` | | `custom_stubs` | removed, see [step 9](#9-replace-the-generators) | | `composer.author` | removed: put authors in the module templates | -| `user_model`, `create_permission` | removed, see [step 6](#6-copy-the-test-and-response-helpers-into-the-application) | +| `user_model` | removed, see [step 6](#6-copy-the-test-and-response-helpers-into-the-application) | +| `create_permission` | removed: only the 2.x generators used it | | `cache.enabled` | removed: the cache is built by `php artisan optimize` | `generators` maps a generator command to a namespace inside the module. It replaces the `namespace` of the @@ -78,6 +80,9 @@ layout: ], ``` +Laravel puts models in `Models` only when the module has a `src/Models` directory, and in the root namespace +otherwise. Add `'make:model' => 'Models'` to always use `Models`, as 2.x did. + One command has one namespace. 2.x had separate API and WEB controllers and requests: generate the other kind with a fully qualified name, for example `make:controller "Modules\Blog\UI\WEB\Controllers\PostController" --module=blog`. The namespaces also apply @@ -102,6 +107,11 @@ The package now loads the resources of every module. For each module in `modules 3. Delete the route service provider that uses `Laraneat\Modules\Support\Concerns\CanLoadRoutesFromDirectory` and remove it from `extra.laravel.providers`. Keep anything it does besides loading routes (route patterns, model bindings, rate limiters) in another provider. +4. Remove the calls that load the module directories themselves: `mergeConfigFrom()`, `loadMigrationsFrom()`, + `loadViewsFrom()` and `loadTranslationsFrom()` with paths of the module. + +The publish tags of the 2.x module providers (`-config`, `-migrations`, `-translations`, `-views`) +are gone with them: update the scripts that call `vendor:publish` with these tags. What 3.0 loads, and how it differs from the 2.x providers: @@ -115,6 +125,8 @@ What 3.0 loads, and how it differs from the 2.x providers: anymore: move the changes into the module. Translation overrides in `lang/vendor/` still work. - **Migrations and commands**: registered only in the console. 2.x registered migrations for HTTP requests too: `Artisan::call('migrate')` during an HTTP request does not see the module migrations anymore. +- **Everything is loaded**: the 2.x provider stub had `loadCommands()`, `loadTranslations()` and `loadViews()` + commented out. 3.0 loads the commands, translations and views of every module that has them. Search for leftovers: @@ -136,16 +148,16 @@ Module routes are loaded by the route groups of `config/modules.php`. For the de Use the prefix, middleware and other attributes of the deleted route service providers. A module whose routes do not fit the groups loads them in its own service provider with `Route::group()`. -Two differences from `CanLoadRoutesFromDirectory`: +Three differences from `CanLoadRoutesFromDirectory`: - Nested directories are appended to the prefix. In 2.x, `routes/v1/admin/stats.php` got the `api/admin` prefix; now it gets `api/v1/admin`. Routes one directory deep keep their URIs. - The files of a directory are loaded before its subdirectories (2.x loaded subdirectories first), and groups are loaded for all modules in turn: first every `api` route, then every `web` route. -- Route files are loaded while the package boots, before the providers of the application boot. 2.x loaded - them after the module route service provider booted. `Route::pattern()` and route macros defined in - `AppServiceProvider::boot()` do not reach module routes anymore: define them in the `register()` method - of a provider, or use `->where()` in the route files. +- Route files are loaded while the package provider boots. 2.x loaded them right after the module route + service provider booted, so `Route::pattern()` calls and route macros in its `boot()` applied to its routes. + When you move them to another provider, define them in its `register()` method, or use `->where()` in the + route files: the `boot()` of another provider may run after the module routes are loaded. When two routes can match the same URL (`posts/{post}` and `posts/export`), make sure the specific one is still registered first. @@ -378,9 +390,11 @@ Two changes in `InteractsWithTestUser`: - The facade is `Laraneat\Modules\Facades\Modules` (was `Laraneat\Modules\Support\Facades\Modules`). The global `Modules` alias points to the new facade. -- `Laraneat\Modules\ModulesRepository` is `Laraneat\Modules\ModuleRepository`. The 2.x exceptions and the - classes of `Laraneat\Modules\Providers` are removed; the exceptions of 3.0 extend - `Laraneat\Modules\Exceptions\ModulesException`. +- `Laraneat\Modules\ModulesRepository` is `Laraneat\Modules\ModuleRepository`. The classes of + `Laraneat\Modules\Providers` are replaced by one `Laraneat\Modules\ModulesServiceProvider`. +- The 2.x exceptions are removed, except `ModuleNotFound`: its `make()`, `makeForName()` and + `makeForNameOrPackageName()` are replaced by `ModuleNotFound::named()`. Every exception of 3.0 implements the + `Laraneat\Modules\Exceptions\ModulesException` interface. - Modules are identified by their directory name, not by the package name. | 2.x | 3.0 | @@ -399,9 +413,11 @@ Two changes in `InteractsWithTestUser`: | `$module->subPath('src')`, `subNamespace('Models')` | `$module->path.'/src'`, `$module->namespace.'\\Models'` | | `$module->getStudlyName()`, `getKebabName()`, `getSnakeName()` | `Str::studly($module->name)`, `$module->name`, `Str::snake(Str::camel($module->name))` | | `$module->getProviders()`, `getAliases()` | `extra.laravel` of the module `composer.json` | +| `(string) $module` | `$module->package` | +| `Module::macro()` | removed: `Module` is a read-only object | ```bash -grep -rnE 'Laraneat\\Modules\\(Support|Enums|Exceptions|Providers|ModulesRepository|Module;)|Modules::' \ +grep -rnE 'Laraneat\\Modules\\(Support|Enums|Exceptions|Providers|Commands|ModulesRepository|Module;)|Modules::' \ app modules tests database routes config bootstrap ``` @@ -436,7 +452,8 @@ The `module:make:*` commands are replaced by the Laravel generators with `--modu | `module:stub:publish`, `custom_stubs` | `php artisan stub:publish`, which customizes the Laravel stubs | `make:data` adds the `Data` suffix to names that do not end with it: `make:data CreatePostDTO` creates -`CreatePostDTOData`. Pass the full name without a suffix, or configure the suffix of laravel-data. +`CreatePostDTOData`. Pass `--suffix=` to keep the name as given, or set `data.commands.make.suffix` (for +example to `DTO`) in the laravel-data config. The generated code follows the Laravel stubs, not the Porto stubs of 2.x. If you relied on the 2.x stubs, put your versions in `stubs/` with `php artisan stub:publish`; they apply to the application and to the @@ -452,6 +469,9 @@ Module migrations are registered with the Laravel migrator: | `module:migrate blog` | `migrate --path=modules/blog/database/migrations` | | `module:migrate:rollback`, `:refresh`, `:reset`, `:status` | `migrate:rollback`, `migrate:refresh`, `migrate:reset`, `migrate:status` | +`migrate` also runs the migrations of the application, in one order by file name. In production it asks for +confirmation, like `module:migrate` did: keep `--force` in deployment scripts. + Update scripts, CI jobs and deployment that call the old commands. `module:delete` now deletes one module at a time, takes the directory name (`blog`, not `app/blog`), and needs `--force` in non-interactive mode. From 8128916022da36e146994d538de30fa6b6805cfe Mon Sep 17 00:00:00 2001 From: Jackardios Date: Thu, 24 Sep 2026 19:05:45 +0300 Subject: [PATCH 38/38] Release 3.0.0 and require the Windows tests to pass --- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 504271e..035f627 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,7 +27,7 @@ jobs: experimental: [false] include: - { os: ubuntu-latest, php: '8.3', dependencies: lowest, experimental: false } - - { os: windows-latest, php: '8.4', dependencies: stable, experimental: true } + - { os: windows-latest, php: '8.4', dependencies: stable, experimental: false } - { os: ubuntu-latest, php: '8.6', dependencies: stable, experimental: true } name: PHP ${{ matrix.php }} - ${{ matrix.dependencies }}${{ matrix.os == 'windows-latest' && ' - Windows' || '' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 56a680a..55ad3c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project are documented in this file. -## 3.0.0 - Unreleased +## 3.0.0 - 2026-09-24 Version 3 is a rewrite. See [UPGRADE.md](UPGRADE.md) for the upgrade from 2.x.