├── .codeclimate.yml ├── .github └── workflows │ ├── coverage.yml │ ├── php-cs-fixer.yml │ ├── phpunit.yml │ └── update-changelog.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── LICENSE ├── README.md ├── art └── modular.png ├── composer.json ├── config.php ├── phpunit.xml ├── src ├── Console │ └── Commands │ │ ├── Database │ │ └── SeedCommand.php │ │ ├── Make │ │ ├── MakeCast.php │ │ ├── MakeChannel.php │ │ ├── MakeCommand.php │ │ ├── MakeComponent.php │ │ ├── MakeController.php │ │ ├── MakeEvent.php │ │ ├── MakeException.php │ │ ├── MakeFactory.php │ │ ├── MakeJob.php │ │ ├── MakeListener.php │ │ ├── MakeLivewire.php │ │ ├── MakeMail.php │ │ ├── MakeMiddleware.php │ │ ├── MakeMigration.php │ │ ├── MakeModel.php │ │ ├── MakeModule.php │ │ ├── MakeNotification.php │ │ ├── MakeObserver.php │ │ ├── MakePolicy.php │ │ ├── MakeProvider.php │ │ ├── MakeRequest.php │ │ ├── MakeResource.php │ │ ├── MakeRule.php │ │ ├── MakeSeeder.php │ │ ├── MakeTest.php │ │ └── Modularize.php │ │ ├── Modularize.php │ │ ├── ModulesCache.php │ │ ├── ModulesClear.php │ │ ├── ModulesList.php │ │ └── ModulesSync.php ├── Exceptions │ ├── CannotFindModuleForPathException.php │ └── Exception.php └── Support │ ├── AutoDiscoveryHelper.php │ ├── DatabaseFactoryHelper.php │ ├── DiscoverEvents.php │ ├── Facades │ └── Modules.php │ ├── FinderCollection.php │ ├── ModularEventServiceProvider.php │ ├── ModularServiceProvider.php │ ├── ModularizedCommandsServiceProvider.php │ ├── ModuleConfig.php │ ├── ModuleRegistry.php │ └── PhpStorm │ ├── ConfigWriter.php │ ├── LaravelConfigWriter.php │ ├── PhpFrameworkWriter.php │ ├── ProjectImlWriter.php │ └── WorkspaceWriter.php ├── stubs ├── .gitkeep ├── ServiceProvider.php ├── ServiceProviderTest.php ├── composer-stub-latest.json ├── composer-stub-v7.json ├── migration.php ├── view.blade.php └── web-routes.php └── tests ├── AutoDiscoveryHelperTest.php ├── Commands ├── Database │ └── SeedCommandTest.php ├── Make │ ├── MakeCastTest.php │ ├── MakeChannelTest.php │ ├── MakeCommandTest.php │ ├── MakeComponentTest.php │ ├── MakeControllerTest.php │ ├── MakeEventTest.php │ ├── MakeExceptionTest.php │ ├── MakeFactoryTest.php │ ├── MakeJobTest.php │ ├── MakeListenerTest.php │ ├── MakeLivewireTest.php │ ├── MakeMailTest.php │ ├── MakeMiddlewareTest.php │ ├── MakeMigrationTest.php │ ├── MakeModelTest.php │ ├── MakeModuleTest.php │ ├── MakeNotificationTest.php │ ├── MakeObserverTest.php │ ├── MakePolicyTest.php │ ├── MakeProviderTest.php │ ├── MakeRequestTest.php │ ├── MakeResourceTest.php │ ├── MakeRuleTest.php │ ├── MakeSeederTest.php │ └── MakeTestTest.php ├── ModulesCacheTest.php ├── ModulesClearTest.php ├── ModulesListTest.php └── ModulesSyncTest.php ├── Concerns ├── PreloadsAppModules.php ├── TestsMakeCommands.php └── WritesToAppFilesystem.php ├── EventDiscovery ├── EventDiscoveryExplicitlyDisabledTest.php ├── EventDiscoveryExplicitlyEnabledTest.php ├── EventDiscoveryImplicitlyDisabledTest.php ├── EventDiscoveryImplicitlyEnabledTest.php └── Laravel11EventDiscoveryImplicitlyEnabledTest.php ├── ModularServiceProviderTest.php ├── ModuleRegistryTest.php ├── TestCase.php ├── stubs ├── laravel-plugin.xml ├── php.xml ├── phpunit.xml ├── project.iml ├── test-stub.php └── workspace.xml └── testbench-core └── app-modules └── test-module ├── composer.json ├── database ├── factories │ └── .gitkeep ├── migrations │ ├── .gitkeep │ └── 2024_04_03_133130_set_up_test-module_module.php └── seeders │ └── .gitkeep ├── resources └── views │ └── index.blade.php ├── routes └── test-module-routes.php └── src ├── Events └── TestEvent.php ├── Listeners └── TestEventListener.php └── Providers └── TestModuleServiceProvider.php /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | exclude_patterns: 2 | - ".github/" 3 | - ".idea/" 4 | - "stubs/" 5 | - "tests/" 6 | - "**/vendor/" 7 | - "**/node_modules/" 8 | - "*.md" 9 | - ".*.yml" 10 | - "LICENSE" 11 | - "composer.json" 12 | - "phpunit.xml" 13 | - "src/Console/Commands/Make/MakeChannel.php" 14 | - "src/Console/Commands/Make/MakeController.php" 15 | - "src/Console/Commands/Make/MakeEvent.php" 16 | - "src/Console/Commands/Make/MakeException.php" 17 | - "src/Console/Commands/Make/MakeFactory.php" 18 | - "src/Console/Commands/Make/MakeJob.php" 19 | - "src/Console/Commands/Make/MakeListener.php" 20 | - "src/Console/Commands/Make/MakeMail.php" 21 | - "src/Console/Commands/Make/MakeMiddleware.php" 22 | - "src/Console/Commands/Make/MakeMigration.php" 23 | - "src/Console/Commands/Make/MakeNotification.php" 24 | - "src/Console/Commands/Make/MakeObserver.php" 25 | - "src/Console/Commands/Make/MakePolicy.php" 26 | - "src/Console/Commands/Make/MakeProvider.php" 27 | - "src/Console/Commands/Make/MakeRequest.php" 28 | - "src/Console/Commands/Make/MakeResource.php" 29 | - "src/Console/Commands/Make/MakeRule.php" 30 | - "src/Console/Commands/Make/MakeSeeder.php" 31 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Code Coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | coverage: 10 | runs-on: ubuntu-latest 11 | 12 | name: Publish code coverage 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v3 17 | 18 | - name: Setup PHP 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: 8.3 22 | extensions: dom, curl, libxml, mbstring, zip, pcntl, bcmath, intl, iconv 23 | coverage: pcov 24 | 25 | - name: Cache dependencies 26 | uses: actions/cache@v2 27 | with: 28 | path: | 29 | vendor 30 | ${{ steps.composer-cache-files-dir.outputs.dir }} 31 | key: ${{ runner.os }}-composer-${{ hashFiles('composer.json') }} 32 | restore-keys: | 33 | ${{ runner.os }}-composer- 34 | 35 | - name: Install dependencies 36 | env: 37 | COMPOSER_DISCARD_CHANGES: true 38 | run: composer install --no-progress --no-interaction --prefer-dist 39 | 40 | - name: Run and publish code coverage 41 | uses: paambaati/codeclimate-action@v5.0.0 42 | env: 43 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 44 | with: 45 | coverageCommand: vendor/bin/phpunit --coverage-clover ${{ github.workspace }}/clover.xml 46 | debug: true 47 | coverageLocations: 48 | "${{github.workspace}}/clover.xml:clover" 49 | -------------------------------------------------------------------------------- /.github/workflows/php-cs-fixer.yml: -------------------------------------------------------------------------------- 1 | name: Code Style 2 | 3 | on: [ pull_request, push ] 4 | 5 | jobs: 6 | coverage: 7 | runs-on: ubuntu-latest 8 | 9 | name: Run code style checks 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v3 14 | 15 | - name: Setup PHP 16 | uses: shivammathur/setup-php@v2 17 | with: 18 | php-version: 8.3 19 | extensions: dom, curl, libxml, mbstring, zip, pcntl, bcmath, intl, iconv 20 | 21 | - name: Cache dependencies 22 | uses: actions/cache@v2 23 | with: 24 | path: | 25 | vendor 26 | ${{ steps.composer-cache-files-dir.outputs.dir }} 27 | key: ${{ runner.os }}-composer-${{ hashFiles('composer.json') }} 28 | restore-keys: | 29 | ${{ runner.os }}-composer- 30 | 31 | - name: Install dependencies 32 | env: 33 | COMPOSER_DISCARD_CHANGES: true 34 | run: composer install --no-progress --no-interaction --prefer-dist 35 | 36 | - name: Run PHP CS Fixer 37 | run: ./vendor/bin/php-cs-fixer fix --diff --dry-run 38 | -------------------------------------------------------------------------------- /.github/workflows/phpunit.yml: -------------------------------------------------------------------------------- 1 | name: PHPUnit 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 14 * * 3' # Run Wednesdays at 2pm EST 8 | 9 | jobs: 10 | php-tests: 11 | strategy: 12 | matrix: 13 | dependency-version: [ stable, lowest ] 14 | os: [ ubuntu-latest, windows-latest ] 15 | laravel: [ 10.*, 11.*, 12.* ] 16 | php: [ 8.1, 8.2, 8.3, 8.4 ] 17 | exclude: 18 | - php: 8.1 19 | laravel: 11.* 20 | - php: 8.1 21 | laravel: 12.* 22 | 23 | runs-on: ${{ matrix.os }} 24 | timeout-minutes: 10 25 | 26 | name: "${{ matrix.php }} / ${{ matrix.laravel }} (${{ matrix.dependency-version }})" 27 | 28 | steps: 29 | - name: Checkout code 30 | uses: actions/checkout@v3 31 | 32 | - name: Setup PHP 33 | uses: shivammathur/setup-php@v2 34 | with: 35 | php-version: ${{ matrix.php }} 36 | extensions: dom, curl, libxml, mbstring, zip, pcntl, bcmath, intl, iconv, fileinfo 37 | tools: composer:v2 38 | 39 | - name: Register composer cache directory 40 | id: composer-cache-files-dir 41 | run: | 42 | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 43 | 44 | - name: Cache dependencies 45 | uses: actions/cache@v3 46 | with: 47 | path: | 48 | vendor 49 | ${{ steps.composer-cache-files-dir.outputs.dir }} 50 | key: ${{ runner.os }}-composer-${{ hashFiles('composer.json') }} 51 | restore-keys: | 52 | ${{ runner.os }}-composer- 53 | 54 | - name: Install dependencies 55 | run: | 56 | composer require --no-progress --no-interaction --prefer-dist --update-with-all-dependencies --prefer-${{ matrix.dependency-version }} "illuminate/support:${{ matrix.laravel }}" 57 | 58 | - name: Execute tests 59 | run: vendor/bin/phpunit 60 | -------------------------------------------------------------------------------- /.github/workflows/update-changelog.yml: -------------------------------------------------------------------------------- 1 | name: Update Changelog 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | 7 | jobs: 8 | update-publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | with: 13 | repository: ${{ github.event.repository.full_name }} 14 | ref: 'main' 15 | 16 | - name: Update changelog 17 | uses: thomaseizinger/keep-a-changelog-new-release@v1 18 | with: 19 | version: ${{ github.event.release.tag_name }} 20 | 21 | - name: Commit changelog back to repo 22 | uses: EndBug/add-and-commit@v9 23 | with: 24 | add: 'CHANGELOG.md' 25 | message: ${{ github.event.release.tag_name }} 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .rnd 3 | .cache 4 | .php_cs.cache 5 | .php-cs-fixer.cache 6 | .composer-history 7 | 8 | /vendor* 9 | /node_modules 10 | 11 | .env 12 | .aws.json 13 | .idea/* 14 | 15 | _ide_helper.php 16 | _ide_macros.php 17 | _ide_helper_models.php 18 | .phpstorm.meta.php 19 | /.generics/ 20 | yarn-error.log 21 | 22 | npm-debug.log* 23 | .phpunit.result.cache 24 | composer.lock 25 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | setRiskyAllowed(true) 7 | ->setIndent("\t") 8 | ->setLineEnding("\n") 9 | ->setRules([ 10 | '@PSR2' => true, 11 | 'function_declaration' => [ 12 | 'closure_function_spacing' => 'none', 13 | 'closure_fn_spacing' => 'none', 14 | ], 15 | 'ordered_imports' => [ 16 | 'sort_algorithm' => 'alpha', 17 | ], 18 | 'array_indentation' => true, 19 | 'braces' => [ 20 | 'allow_single_line_closure' => true, 21 | ], 22 | 'no_break_comment' => false, 23 | 'return_type_declaration' => [ 24 | 'space_before' => 'none', 25 | ], 26 | 'blank_line_after_opening_tag' => true, 27 | 'compact_nullable_typehint' => true, 28 | 'cast_spaces' => true, 29 | 'concat_space' => [ 30 | 'spacing' => 'none', 31 | ], 32 | 'declare_equal_normalize' => [ 33 | 'space' => 'none', 34 | ], 35 | 'function_typehint_space' => true, 36 | 'new_with_braces' => true, 37 | 'method_argument_space' => true, 38 | 'no_empty_statement' => true, 39 | 'no_empty_comment' => true, 40 | 'no_empty_phpdoc' => true, 41 | 'no_extra_blank_lines' => [ 42 | 'tokens' => [ 43 | 'extra', 44 | 'use', 45 | 'use_trait', 46 | 'return', 47 | ], 48 | ], 49 | 'no_leading_import_slash' => true, 50 | 'no_leading_namespace_whitespace' => true, 51 | 'no_blank_lines_after_class_opening' => true, 52 | 'no_blank_lines_after_phpdoc' => true, 53 | 'no_whitespace_in_blank_line' => false, 54 | 'no_whitespace_before_comma_in_array' => true, 55 | 'no_useless_else' => true, 56 | 'no_useless_return' => true, 57 | 'single_trait_insert_per_statement' => true, 58 | 'psr_autoloading' => true, 59 | 'dir_constant' => true, 60 | 'single_line_comment_style' => [ 61 | 'comment_types' => ['hash'], 62 | ], 63 | 'include' => true, 64 | 'is_null' => true, 65 | 'linebreak_after_opening_tag' => true, 66 | 'lowercase_cast' => true, 67 | 'lowercase_static_reference' => true, 68 | 'magic_constant_casing' => true, 69 | 'magic_method_casing' => true, 70 | 'class_attributes_separation' => [ 71 | // TODO: This can be reverted when https://github.com/FriendsOfPHP/PHP-CS-Fixer/pull/5869 is merged 72 | 'elements' => ['const' => 'one', 'method' => 'one', 'property' => 'one'], 73 | ], 74 | 'modernize_types_casting' => true, 75 | 'native_function_casing' => true, 76 | 'native_function_type_declaration_casing' => true, 77 | 'no_alias_functions' => true, 78 | 'no_multiline_whitespace_around_double_arrow' => true, 79 | 'multiline_whitespace_before_semicolons' => true, 80 | 'no_short_bool_cast' => true, 81 | 'no_unused_imports' => true, 82 | 'no_php4_constructor' => true, 83 | 'no_singleline_whitespace_before_semicolons' => true, 84 | 'no_spaces_around_offset' => true, 85 | 'no_trailing_comma_in_list_call' => true, 86 | 'no_trailing_comma_in_singleline_array' => true, 87 | 'normalize_index_brace' => true, 88 | 'object_operator_without_whitespace' => true, 89 | 'phpdoc_annotation_without_dot' => true, 90 | 'phpdoc_indent' => true, 91 | 'phpdoc_no_package' => true, 92 | 'phpdoc_no_access' => true, 93 | 'phpdoc_no_useless_inheritdoc' => true, 94 | 'phpdoc_single_line_var_spacing' => true, 95 | 'phpdoc_trim' => true, 96 | 'phpdoc_types' => true, 97 | 'semicolon_after_instruction' => true, 98 | 'array_syntax' => [ 99 | 'syntax' => 'short', 100 | ], 101 | 'list_syntax' => [ 102 | 'syntax' => 'short', 103 | ], 104 | 'short_scalar_cast' => true, 105 | 'single_blank_line_before_namespace' => true, 106 | 'single_quote' => true, 107 | 'standardize_not_equals' => true, 108 | 'ternary_operator_spaces' => true, 109 | 'whitespace_after_comma_in_array' => true, 110 | 'not_operator_with_successor_space' => true, 111 | 'trailing_comma_in_multiline' => true, 112 | 'trim_array_spaces' => true, 113 | 'binary_operator_spaces' => true, 114 | 'unary_operator_spaces' => true, 115 | 'php_unit_method_casing' => [ 116 | 'case' => 'snake_case', 117 | ], 118 | 'php_unit_test_annotation' => [ 119 | 'style' => 'prefix', 120 | ], 121 | ]) 122 | ->setFinder( 123 | PhpCsFixer\Finder::create() 124 | ->exclude('.circleci') 125 | ->exclude('bin') 126 | ->exclude('node_modules') 127 | ->exclude('vendor') 128 | ->exclude('vendor-10') // used for local offline testing 129 | ->exclude('vendor-11') // used for local offline testing 130 | ->notPath('.phpstorm.meta.php') 131 | ->notPath('_ide_helper.php') 132 | ->notPath('artisan') 133 | ->in(__DIR__) 134 | ); 135 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This changelog follows the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format, 4 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 5 | 6 | ## [Unreleased] 7 | 8 | ### Added 9 | 10 | - Added support for auto-aliasing module classes in tinker sessions 11 | 12 | ## [2.2.0] - 2024-04-05 13 | 14 | ### Added 15 | 16 | - The modules sync command now adds modules to PhpStorm exclude path, preventing double-registration of modules 17 | 18 | ## [2.1.0] - 2024-03-18 19 | 20 | ### Added 21 | 22 | - Added support for Laravel 11 23 | - Added support for event discovery 24 | 25 | ### Fixed 26 | 27 | - Fixed an error with how module command signatures were set 28 | 29 | ## [2.0.0] - 2023-05-19 30 | 31 | ### Changed 32 | 33 | - Dropped support for older versions of PHP and Laravel. If you are using Laravel 7 or 8, or PHP 7, please use the 1.x releases 34 | 35 | ## [1.12.0] - 2023-05-19 36 | 37 | ### Added 38 | 39 | - Added support for factory model name resolution inside modules 40 | 41 | ### Fixed 42 | 43 | - Added support for new `make:command` changes in Laravel 10 44 | 45 | ## [1.11.0] - 2023-02-14 46 | 47 | ### Changed 48 | 49 | - Updated version constraints to add Laravel 10 support and drop automated testing for old versions of PHP and Laravel 50 | 51 | ## [1.10.0] - 2022-08-12 52 | 53 | ### Fixed 54 | 55 | - Improved path normalization on Windows (thanks to @Sophist-UK) 56 | 57 | ## [1.9.0] - 2022-07-06 58 | 59 | ### Fixed 60 | 61 | - Addressed issue where `make:migration` and `make:livewire` were not loading the custom `--module` option 62 | - Added additional tests for `make:` commands to catch necessary changes quicker in the future 63 | - Passing a `--module` flag for an unknown module now triggers a console error 64 | 65 | ## [1.8.0] - 2022-06-04 66 | 67 | ### Added 68 | 69 | - Added support for Blade component namespaces (i.e. ``) 70 | 71 | ### Fixed 72 | 73 | - Fixed issue with `make:seeder` command introduced in Laravel 9.6.0 74 | 75 | ## [1.7.0] - 2022-02-11 76 | 77 | ### Added 78 | 79 | - Added support for Laravel 9.x 80 | 81 | ## [1.6.0] 82 | 83 | ### Added 84 | 85 | - Added support for custom module stubs 86 | 87 | ### Fixed 88 | 89 | - Only register the `make:livewire` integration if Livewire is installed 90 | 91 | ## [1.5.2] 92 | 93 | ### Added 94 | 95 | - Added support for syncing modules to PhpStorm library roots 96 | 97 | ## [1.5.1] 98 | 99 | ### Added 100 | 101 | - Added support for `make:cast` 102 | 103 | ## [1.5.0] 104 | 105 | ### Added 106 | 107 | - Added support for Livewire's `make:livewire` command 108 | 109 | ## [1.4.0] 110 | 111 | ### Added 112 | 113 | - Added support for `--module` in `php artisan db:seed` 114 | 115 | ### Fixed 116 | 117 | - Create seeders in the correct namespace when `--module` flag is used in Laravel 8+ 118 | - Create factories in the correct namespace when `--module` flag is used in Laravel 8+ 119 | - Apply module namespace to models when creating a factory in a module 120 | 121 | ## [1.3.1] 122 | 123 | ### Fixed 124 | 125 | - Added better handling of missing directories 126 | 127 | ## [1.3.0] 128 | 129 | ### Added 130 | 131 | - Added support for translations in modules 132 | 133 | ### Changed 134 | 135 | - Switched to `diglactic/laravel-breadcrumbs` for breadcrumbs check 136 | 137 | ## [1.2.2] 138 | 139 | ### Added 140 | 141 | - Added better patching for PHPStorm config files to minimize diffs 142 | 143 | ## [1.2.0] 144 | 145 | ### Added 146 | 147 | - Support for auto-registering Laravel 8 factory classes 148 | 149 | ### Fixed 150 | 151 | - Better Windows support 152 | - Support for composer 2.0 153 | - Improves the file scanning efficiency of the `AutoDiscoveryHelper` 154 | 155 | ## [1.1.0] 156 | 157 | ### Added 158 | 159 | - Adds support for `php artisan make:component` 160 | - `php artisan modules:sync` will now update additional PhpStorm config files 161 | - Partial support for `--all` on `make:model` 162 | - Initial support for component auto-discovery 163 | - Switched to single `app-modules/*` composer repository rather than new repositories for each module 164 | - Added description field to generated `composer.json` file 165 | - Moved tests from `autoload-dev` to `autoload` because composer doesn't support 166 | `autoload-dev` for non-root configs 167 | - Added improved support for Laravel 8 factory classes 168 | 169 | ## [1.0.1] 170 | 171 | ### Changed 172 | 173 | - Introduces a few improvements to the default composer.json format. 174 | 175 | ## [1.0.0] 176 | 177 | ### Added 178 | 179 | - Initial release 180 | 181 | * * * 182 | 183 | #### "Keep a Changelog" - Types of Changes 184 | 185 | - `Added` for new features. 186 | - `Changed` for changes in existing functionality. 187 | - `Deprecated` for soon-to-be removed features. 188 | - `Removed` for now removed features. 189 | - `Fixed` for any bug fixes. 190 | - `Security` in case of vulnerabilities. 191 | 192 | [Unreleased]: https://github.com/InterNACHI/modular/compare/1.12.0...HEAD 193 | 194 | [1.12.0]: https://github.com/InterNACHI/modular/compare/1.11.0...1.12.0 195 | 196 | [1.11.0]: https://github.com/InterNACHI/modular/compare/1.10.0...1.11.0 197 | 198 | [1.10.0]: https://github.com/InterNACHI/modular/compare/1.9.0...1.10.0 199 | 200 | [1.9.0]: https://github.com/InterNACHI/modular/compare/1.8.0...1.9.0 201 | 202 | [1.8.0]: https://github.com/InterNACHI/modular/compare/1.7.0...1.8.0 203 | 204 | [1.7.0]: https://github.com/InterNACHI/modular/compare/1.6.0...1.7.0 205 | 206 | [1.6.0]: https://github.com/InterNACHI/modular/compare/1.5.2...1.6.0 207 | 208 | [1.5.2]: https://github.com/InterNACHI/modular/compare/1.5.1...1.5.2 209 | 210 | [1.5.1]: https://github.com/InterNACHI/modular/compare/1.5.0...1.5.1 211 | 212 | [1.5.0]: https://github.com/InterNACHI/modular/compare/1.4.0...1.5.0 213 | 214 | [1.4.0]: https://github.com/InterNACHI/modular/compare/1.3.1...1.4.0 215 | 216 | [1.3.1]: https://github.com/InterNACHI/modular/compare/1.3.0...1.3.1 217 | 218 | [1.3.0]: https://github.com/InterNACHI/modular/compare/1.2.2...1.3.0 219 | 220 | [1.2.2]: https://github.com/InterNACHI/modular/compare/1.2.1...1.2.2 221 | 222 | [1.2.1]: https://github.com/InterNACHI/modular/compare/1.2.0...1.2.1 223 | 224 | [1.2.0]: https://github.com/InterNACHI/modular/compare/1.1.0...1.2.0 225 | 226 | [1.0.1]: https://github.com/InterNACHI/modular/compare/1.0.1...1.1.0 227 | 228 | [1.0.1]: https://github.com/InterNACHI/modular/compare/1.0.0...1.0.1 229 | 230 | [1.0.0]: https://github.com/InterNACHI/modular/releases/tag/1.0.0 231 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2022 InterNACHI 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Modular 2 | 3 | # `internachi/modular` 4 | 5 |
6 | 7 | Build Status 11 | 12 | 13 | Coverage Status 17 | 18 | 19 | Latest Stable Release 23 | 24 | 25 | MIT Licensed 29 | 30 | 31 | Follow @inxilpro on Twitter 35 | 36 | 37 | Follow @chris@any.dev on Mastodon 41 | 42 |
43 | 44 | `internachi/modular` is a module system for Laravel applications. It uses 45 | [Composer path repositories](https://getcomposer.org/doc/05-repositories.md#path) for autoloading, 46 | and [Laravel package discovery](https://laravel.com/docs/11.x/packages#package-discovery) for module 47 | initialization, and then provides minimal tooling to fill in any gaps. 48 | 49 | This project is as much a set of conventions as it is a package. The fundamental idea 50 | is that you can create “modules” in a separate `app-modules/` directory, which allows you to 51 | better organize large projects. These modules use the existing 52 | [Laravel package system](https://laravel.com/docs/11.x/packages), and follow existing Laravel 53 | conventions. 54 | 55 | - [Walkthrough Video](#walkthrough-video) 56 | - [Installation](#installation) 57 | - [Usage](#usage) 58 | - [Comparison to `nwidart/laravel-modules`](#comparison-to-nwidartlaravel-modules) 59 | 60 | ## Walkthrough Video 61 | 62 | [![Intro video](https://embed-ssl.wistia.com/deliveries/98ebc7e01537a644df2d3af93d928257.jpg?image_crop_resized=1600x900&image_play_button=true&image_play_button_size=2x&image_play_button_color=1e71e7e0)](https://internachi.wistia.com/medias/pivaxithl7?wvideo=pivaxithl7) 63 | 64 | ## Installation 65 | 66 | To get started, run: 67 | 68 | ```shell script 69 | composer require internachi/modular 70 | ``` 71 | 72 | Laravel will auto-discover the package and everything will be automatically set up for you. 73 | 74 | ### Publish the config 75 | 76 | While not required, it's highly recommended that you customize your default namespace 77 | for modules. By default, this is set to `Modules\`, which works just fine but makes it 78 | harder to extract your module to a separate package should you ever choose to. 79 | 80 | We recommend configuring a organization namespace (we use `"InterNACHI"`, for example). 81 | To do this, you'll need to publish the package config: 82 | 83 | ```shell script 84 | php artisan vendor:publish --tag=modular-config 85 | ``` 86 | 87 | ### Create a module 88 | 89 | Next, let's create a module: 90 | 91 | ```shell script 92 | php artisan make:module my-module 93 | ``` 94 | 95 | Modular will scaffold up a new module for you: 96 | 97 | ``` 98 | app-modules/ 99 | my-module/ 100 | composer.json 101 | src/ 102 | tests/ 103 | routes/ 104 | resources/ 105 | database/ 106 | ``` 107 | 108 | It will also add two new entries to your app's `composer.json` file. The first entry registers 109 | `./app-modules/my-module/` as a [path repository](https://getcomposer.org/doc/05-repositories.md#path), 110 | and the second requires `modules/my-module:*` (like any other Composer dependency). 111 | 112 | Modular will then remind you to perform a Composer update, so let's do that now: 113 | 114 | ```shell script 115 | composer update modules/my-module 116 | ``` 117 | 118 | ### Optional: Config synchronization 119 | 120 | You can run the sync command to make sure that your project is set up 121 | for module support: 122 | 123 | ```shell script 124 | php artisan modules:sync 125 | ``` 126 | 127 | This will add a `Modules` test suite to your `phpunit.xml` file (if one exists) 128 | and update your [PhpStorm Laravel plugin](https://plugins.jetbrains.com/plugin/7532-laravel) 129 | configuration (if it exists) to properly find your module's views. 130 | 131 | It is safe to run this command at any time, as it will only add missing configurations. 132 | You may even want to add it to your `post-autoload-dump` scripts in your application's 133 | `composer.json` file. 134 | 135 | ## Usage 136 | 137 | All modules follow existing Laravel conventions, and auto-discovery 138 | should work as expected in most cases: 139 | 140 | - Commands are auto-registered with Artisan 141 | - Migrations will be run by the Migrator 142 | - Factories are auto-loaded for `factory()` 143 | - Policies are auto-discovered for your Models 144 | - Blade components will be auto-discovered 145 | - Event listeners will be auto-discovered 146 | 147 | ### Commands 148 | 149 | #### Package Commands 150 | 151 | We provide a few helper commands: 152 | 153 | - `php artisan make:module` — scaffold a new module 154 | - `php artisan modules:cache` — cache the loaded modules for slightly faster auto-discovery 155 | - `php artisan modules:clear` — clear the module cache 156 | - `php artisan modules:sync` — update project configs (like `phpunit.xml`) with module settings 157 | - `php artisan modules:list` — list all modules 158 | 159 | #### Laravel “`make:`” Commands 160 | 161 | We also add a `--module=` option to most Laravel `make:` commands so that you can 162 | use all the existing tooling that you know. The commands themselves are exactly the 163 | same, which means you can use your [custom stubs](https://laravel.com/docs/11.x/artisan#stub-customization) 164 | and everything else Laravel provides: 165 | 166 | - `php artisan make:cast MyModuleCast --module=[module name]` 167 | - `php artisan make:controller MyModuleController --module=[module name]` 168 | - `php artisan make:command MyModuleCommand --module=[module name]` 169 | - `php artisan make:component MyModuleComponent --module=[module name]` 170 | - `php artisan make:channel MyModuleChannel --module=[module name]` 171 | - `php artisan make:event MyModuleEvent --module=[module name]` 172 | - `php artisan make:exception MyModuleException --module=[module name]` 173 | - `php artisan make:factory MyModuleFactory --module=[module name]` 174 | - `php artisan make:job MyModuleJob --module=[module name]` 175 | - `php artisan make:listener MyModuleListener --module=[module name]` 176 | - `php artisan make:mail MyModuleMail --module=[module name]` 177 | - `php artisan make:middleware MyModuleMiddleware --module=[module name]` 178 | - `php artisan make:model MyModule --module=[module name]` 179 | - `php artisan make:notification MyModuleNotification --module=[module name]` 180 | - `php artisan make:observer MyModuleObserver --module=[module name]` 181 | - `php artisan make:policy MyModulePolicy --module=[module name]` 182 | - `php artisan make:provider MyModuleProvider --module=[module name]` 183 | - `php artisan make:request MyModuleRequest --module=[module name]` 184 | - `php artisan make:resource MyModule --module=[module name]` 185 | - `php artisan make:rule MyModuleRule --module=[module name]` 186 | - `php artisan make:seeder MyModuleSeeder --module=[module name]` 187 | - `php artisan make:test MyModuleTest --module=[module name]` 188 | 189 | #### Other Laravel Commands 190 | 191 | In addition to adding a `--module` option to most `make:` commands, we’ve also added the same 192 | option to the `db:seed` command. If you pass the `--module` option to `db:seed`, it will look 193 | for your seeder within your module namespace: 194 | 195 | - `php artisan db:seed --module=[module name]` will try to call `Modules\MyModule\Database\Seeders\DatabaseSeeder` 196 | - `php artisan db:seed --class=MySeeder --module=[module name]` will try to call `Modules\MyModule\Database\Seeders\MySeeder` 197 | 198 | #### Vendor Commands 199 | 200 | We can also add the `--module` option to commands in 3rd-party packages. The first package 201 | that we support is Livewire. If you have Livewire installed, you can run: 202 | 203 | - `php artisan make:livewire counter --module=[module name]` 204 | 205 | ### Blade Components 206 | 207 | Your [Laravel Blade components](https://laravel.com/docs/blade#components) will be 208 | automatically registered for you under a [component namespace](https://laravel.com/docs/9.x/blade#manually-registering-package-components). 209 | A few examples: 210 | 211 | | File | Component | 212 | |--------------------------------------------------------------------|--------------------------------| 213 | | `app-modules/demo/src/View/Components/Basic.php` | `` | 214 | | `app-modules/demo/src/View/Components/Nested/One.php` | `` | 215 | | `app-modules/demo/src/View/Components/Nested/Two.php` | `` | 216 | | `app-modules/demo/resources/components/anonymous.blade.php` | `` | 217 | | `app-modules/demo/resources/components/anonymous/index.blade.php` | `` | 218 | | `app-modules/demo/resources/components/anonymous/nested.blade.php` | `` | 219 | 220 | ### Translations 221 | 222 | Your [Laravel Translations](https://laravel.com/docs/11.x/localization#defining-translation-strings) will also 223 | be automatically registered under a component namespace for you. For example, if you have a translation file 224 | at: 225 | 226 | `app-modules/demo/resources/lang/en/messages.php` 227 | 228 | You could access those translations with: `__('demo::messages.welcome');` 229 | 230 | ### Customizing the Default Module Structure 231 | 232 | When you call `make:module`, Modular will scaffold some basic boilerplate for you. If you 233 | would like to customize this behavior, you can do so by publishing the `app-modules.php` 234 | config file and adding your own stubs. 235 | 236 | Both filenames and file contents support a number of placeholders. These include: 237 | 238 | - `StubBasePath` 239 | - `StubModuleNamespace` 240 | - `StubComposerNamespace` 241 | - `StubModuleNameSingular` 242 | - `StubModuleNamePlural` 243 | - `StubModuleName` 244 | - `StubClassNamePrefix` 245 | - `StubComposerName` 246 | - `StubMigrationPrefix` 247 | - `StubFullyQualifiedTestCaseBase` 248 | - `StubTestCaseBase` 249 | 250 | ## Comparison to `nwidart/laravel-modules` 251 | 252 | [Laravel Modules](https://nwidart.com/laravel-modules) is a great package that’s been 253 | around since 2016 and is used by 1000's of projects. The main reason we decided to build 254 | our own module system rather than using `laravel-modules` comes down to two decisions: 255 | 256 | 1. We wanted something that followed Laravel conventions rather than using its own 257 | directory structure/etc. 258 | 2. We wanted something that felt “lighter weight” 259 | 260 | If you are building a CMS that needs to support 3rd-party modules that can be dynamically 261 | enabled and disabled, Laravel Modules will be a better fit. 262 | 263 | On the other hand, if you're mostly interested in modules for organization, and want to 264 | stick closely to Laravel conventions, we’d highly recommend giving InterNACHI/Modular a try! 265 | -------------------------------------------------------------------------------- /art/modular.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/InterNACHI/modular/e7ff4074001d3df50d9ce877385c55d88e254484/art/modular.png -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "internachi/modular", 3 | "description": "Modularize your Laravel apps", 4 | "keywords": [ 5 | "laravel", 6 | "modules", 7 | "modular", 8 | "module" 9 | ], 10 | "authors": [ 11 | { 12 | "name": "Chris Morrell", 13 | "homepage": "http://www.cmorrell.com" 14 | } 15 | ], 16 | "type": "library", 17 | "license": "MIT", 18 | "require": { 19 | "php": ">=8.0", 20 | "ext-simplexml": "*", 21 | "ext-dom": "*", 22 | "composer/composer": "^2.1", 23 | "illuminate/support": "^9|^10|^11|^12|13.x-dev|dev-master|dev-main" 24 | }, 25 | "require-dev": { 26 | "orchestra/testbench": "^7.52|^8.33|^9.11|^10.0|dev-master|dev-main", 27 | "friendsofphp/php-cs-fixer": "^3.14", 28 | "mockery/mockery": "^1.5", 29 | "phpunit/phpunit": "^9.5|^10.5|^11.5", 30 | "ext-json": "*", 31 | "livewire/livewire": "^2.5|^3.0" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "InterNACHI\\Modular\\": "src/" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "InterNACHI\\Modular\\Tests\\": "tests/" 41 | } 42 | }, 43 | "scripts": { 44 | "fix-style": "vendor/bin/php-cs-fixer fix", 45 | "check-style": "vendor/bin/php-cs-fixer fix --diff --dry-run" 46 | }, 47 | "extra": { 48 | "laravel": { 49 | "providers": [ 50 | "InterNACHI\\Modular\\Support\\ModularServiceProvider", 51 | "InterNACHI\\Modular\\Support\\ModularizedCommandsServiceProvider", 52 | "InterNACHI\\Modular\\Support\\ModularEventServiceProvider" 53 | ], 54 | "aliases": { 55 | "Modules": "InterNACHI\\Modular\\Support\\Facades\\Modules" 56 | } 57 | } 58 | }, 59 | "minimum-stability": "dev", 60 | "prefer-stable": true 61 | } 62 | -------------------------------------------------------------------------------- /config.php: -------------------------------------------------------------------------------- 1 | 'Modules', 22 | 23 | /* 24 | |-------------------------------------------------------------------------- 25 | | Composer "Vendor" Name 26 | |-------------------------------------------------------------------------- 27 | | 28 | | This is the prefix used for your composer.json file. This should be the 29 | | kebab-case version of your module namespace (if left null, we will 30 | | generate the kebab-case version for you). 31 | | 32 | */ 33 | 34 | 'modules_vendor' => null, 35 | 36 | /* 37 | |-------------------------------------------------------------------------- 38 | | Modules Directory 39 | |-------------------------------------------------------------------------- 40 | | 41 | | If you want to install modules in a custom directory, you can do so here. 42 | | Keeping the default `app-modules/` directory is highly recommended, 43 | | though, as it keeps your modules near the rest of your application code 44 | | in an alpha-sorted directory listing. 45 | | 46 | */ 47 | 48 | 'modules_directory' => 'app-modules', 49 | 50 | /* 51 | |-------------------------------------------------------------------------- 52 | | Base Test Case 53 | |-------------------------------------------------------------------------- 54 | | 55 | | This is the base TestCase class name that auto-generated Tests should 56 | | extend. By default it assumes the default \Tests\TestCase exists. 57 | | 58 | */ 59 | 60 | 'tests_base' => 'Tests\TestCase', 61 | 62 | /* 63 | |-------------------------------------------------------------------------- 64 | | Custom Stubs 65 | |-------------------------------------------------------------------------- 66 | | 67 | | If you would like to use your own custom stubs for new modules, you can 68 | | configure those here. This should be an array where the key is the path 69 | | relative to the module and the value is the absolute path to the stub 70 | | stub file. Destination paths and contents support placeholders. See the 71 | | README.md file for more information. 72 | | 73 | | For example: 74 | | 75 | | 'stubs' => [ 76 | | 'src/Providers/StubClassNamePrefixServiceProvider.php' => base_path('stubs/app-modules/ServiceProvider.php'), 77 | | ], 78 | */ 79 | 80 | 'stubs' => null, 81 | 82 | /* 83 | |-------------------------------------------------------------------------- 84 | | Custom override of event discovery 85 | |-------------------------------------------------------------------------- 86 | | 87 | | This is a custom override of the event discovery feature. If you want to 88 | | disable event discovery, set this to false. If you want to enable event 89 | | discovery, set this to true. We will still check the app namespace for 90 | | the presence of event discovery. 91 | */ 92 | 93 | 'should_discover_events' => null, 94 | ]; 95 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | ./src 11 | 12 | 13 | 14 | 15 | ./tests 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Console/Commands/Database/SeedCommand.php: -------------------------------------------------------------------------------- 1 | module()) { 15 | $default = $this->getDefinition()->getOption('class')->getDefault(); 16 | $class = $this->input->getOption('class'); 17 | 18 | if ($class === $default) { 19 | $class = $module->qualify($default); 20 | } elseif (! Str::contains($class, 'Database\\Seeders')) { 21 | $class = $module->qualify("Database\\Seeders\\{$class}"); 22 | } 23 | 24 | return $this->laravel->make($class) 25 | ->setContainer($this->laravel) 26 | ->setCommand($this); 27 | } 28 | 29 | return parent::getSeeder(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Console/Commands/Make/MakeCast.php: -------------------------------------------------------------------------------- 1 | module(); 15 | 16 | $stub = parent::replaceClass($stub, $name); 17 | 18 | if ($module) { 19 | $cli_name = Str::of($name)->classBasename()->kebab(); 20 | 21 | $find = [ 22 | '{{command}}', 23 | '{{ command }}', 24 | 'dummy:command', 25 | 'command:name', 26 | "app:{$cli_name}", 27 | ]; 28 | 29 | $stub = str_replace($find, "{$module->name}:{$cli_name}", $stub); 30 | } 31 | 32 | return $stub; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Console/Commands/Make/MakeComponent.php: -------------------------------------------------------------------------------- 1 | module()) { 14 | return $module->path("resources/views/{$path}"); 15 | } 16 | 17 | return parent::viewPath($path); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Console/Commands/Make/MakeController.php: -------------------------------------------------------------------------------- 1 | module()) { 16 | return parent::parseModel($model); 17 | } 18 | 19 | if (preg_match('([^A-Za-z0-9_/\\\\])', $model)) { 20 | throw new InvalidArgumentException('Model name contains invalid characters.'); 21 | } 22 | 23 | $model = trim(str_replace('/', '\\', $model), '\\'); 24 | 25 | if (! Str::startsWith($model, $namespace = $module->namespaces->first())) { 26 | $model = $namespace.$model; 27 | } 28 | 29 | return $model; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Console/Commands/Make/MakeEvent.php: -------------------------------------------------------------------------------- 1 | module()) { 15 | $model = $this->option('model') 16 | ? $this->qualifyModel($this->option('model')) 17 | : $this->qualifyModel($this->guessModelName($name)); 18 | 19 | $models_namespace = $module->qualify('Models'); 20 | 21 | if (Str::startsWith($model, "{$models_namespace}\\")) { 22 | $extra_namespace = trim(Str::after(Str::beforeLast($model, '\\'), $models_namespace), '\\'); 23 | $namespace = rtrim($module->qualify("Database\\Factories\\{$extra_namespace}"), '\\'); 24 | } else { 25 | $namespace = $module->qualify('Database\\Factories'); 26 | } 27 | 28 | $replacements = [ 29 | '{{ factoryNamespace }}' => $namespace, 30 | '{{factoryNamespace}}' => $namespace, 31 | 'namespace Database\Factories;' => "namespace {$namespace};", // Early Laravel 8 didn't use a placeholder 32 | ]; 33 | 34 | $stub = str_replace(array_keys($replacements), array_values($replacements), $stub); 35 | } 36 | 37 | return parent::replaceNamespace($stub, $name); 38 | } 39 | 40 | protected function guessModelName($name) 41 | { 42 | if ($module = $this->module()) { 43 | if (Str::endsWith($name, 'Factory')) { 44 | $name = substr($name, 0, -7); 45 | } 46 | 47 | $modelName = $this->qualifyModel($name); 48 | if (class_exists($modelName)) { 49 | return $modelName; 50 | } 51 | 52 | return $module->qualify('Models\\Model'); 53 | } 54 | 55 | return parent::guessModelName($name); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Console/Commands/Make/MakeJob.php: -------------------------------------------------------------------------------- 1 | option('event'); 16 | 17 | if (Modules::moduleForClass($name)) { 18 | $stub = str_replace( 19 | ['DummyEvent', '{{ event }}'], 20 | class_basename($event), 21 | GeneratorCommand::buildClass($name) 22 | ); 23 | 24 | return str_replace( 25 | ['DummyFullEvent', '{{ eventNamespace }}'], 26 | trim($event, '\\'), 27 | $stub 28 | ); 29 | } 30 | 31 | return parent::buildClass($name); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Console/Commands/Make/MakeLivewire.php: -------------------------------------------------------------------------------- 1 | module()) { 26 | Config::set('livewire.class_namespace', $module->qualify('Http\\Livewire')); 27 | Config::set('livewire.view_path', $module->path('resources/views/livewire')); 28 | 29 | $app = $this->getLaravel(); 30 | 31 | $defaultManifestPath = $app['livewire']->isRunningServerless() 32 | ? '/tmp/storage/bootstrap/cache/livewire-components.php' 33 | : $app->bootstrapPath('cache/livewire-components.php'); 34 | 35 | $componentsFinder = new LivewireComponentsFinder( 36 | new Filesystem(), 37 | Config::get('livewire.manifest_path') ?? $defaultManifestPath, 38 | $module->path('src/Http/Livewire') 39 | ); 40 | 41 | $app->instance(LivewireComponentsFinder::class, $componentsFinder); 42 | } 43 | 44 | parent::handle(); 45 | } 46 | 47 | protected function createClass($force = false, $inline = false) 48 | { 49 | if ($module = $this->module()) { 50 | $name = Str::of($this->argument('name')) 51 | ->split('/[.\/(\\\\)]+/') 52 | ->map([Str::class, 'studly']) 53 | ->join(DIRECTORY_SEPARATOR); 54 | 55 | $classPath = $module->path('src/Http/Livewire/'.$name.'.php'); 56 | 57 | if (File::exists($classPath) && ! $force) { 58 | $this->line(" WHOOPS-IE-TOOTLES 😳 \n"); 59 | $this->line("Class already exists: {$this->parser->relativeClassPath()}"); 60 | 61 | return false; 62 | } 63 | 64 | $this->ensureDirectoryExists($classPath); 65 | 66 | File::put($classPath, $this->parser->classContents($inline)); 67 | 68 | $component_name = Str::of($name) 69 | ->explode('/') 70 | ->filter() 71 | ->map([Str::class, 'kebab']) 72 | ->implode('.'); 73 | 74 | $fully_qualified_component = Str::of($this->argument('name')) 75 | ->prepend('Http/Livewire/') 76 | ->split('/[.\/(\\\\)]+/') 77 | ->map([Str::class, 'studly']) 78 | ->join('\\'); 79 | 80 | Livewire::component("{$module->name}::{$component_name}", $module->qualify($fully_qualified_component)); 81 | 82 | return $classPath; 83 | } 84 | 85 | return parent::createClass($force, $inline); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Console/Commands/Make/MakeMail.php: -------------------------------------------------------------------------------- 1 | module()) { 17 | $app_directory = $this->laravel->databasePath('migrations'); 18 | $module_directory = $module->path('database/migrations'); 19 | 20 | $path = str_replace($app_directory, $module_directory, $path); 21 | 22 | $filesystem = $this->getLaravel()->make(Filesystem::class); 23 | if (! $filesystem->isDirectory($module_directory)) { 24 | $filesystem->makeDirectory($module_directory, 0755, true); 25 | } 26 | } 27 | 28 | return $path; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Console/Commands/Make/MakeModel.php: -------------------------------------------------------------------------------- 1 | module()) { 14 | $rootNamespace = rtrim($module->namespaces->first(), '\\'); 15 | } 16 | 17 | return $rootNamespace.'\Models'; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Console/Commands/Make/MakeModule.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 82 | $this->module_registry = $module_registry; 83 | } 84 | 85 | public function handle() 86 | { 87 | $this->module_name = Str::kebab($this->argument('name')); 88 | $this->class_name_prefix = Str::studly($this->argument('name')); 89 | $this->module_namespace = config('app-modules.modules_namespace', 'Modules'); 90 | $this->composer_namespace = config('app-modules.modules_vendor') ?? Str::kebab($this->module_namespace); 91 | $this->composer_name = "{$this->composer_namespace}/{$this->module_name}"; 92 | $this->base_path = $this->module_registry->getModulesPath().'/'.$this->module_name; 93 | 94 | $this->setUpStyles(); 95 | 96 | $this->newLine(); 97 | 98 | if ($this->shouldAbortToPublishConfig()) { 99 | return 0; 100 | } 101 | 102 | $this->ensureModulesDirectoryExists(); 103 | 104 | $this->writeStubs(); 105 | $this->updateCoreComposerConfig(); 106 | 107 | $this->call(ModulesClear::class); 108 | 109 | $this->newLine(); 110 | $this->line("Please run composer update {$this->composer_name}"); 111 | $this->newLine(); 112 | 113 | $this->module_registry->reload(); 114 | 115 | return 0; 116 | } 117 | 118 | protected function shouldAbortToPublishConfig(): bool 119 | { 120 | if ( 121 | 'Modules' !== $this->module_namespace 122 | || true === $this->option('accept-default-namespace') 123 | || $this->module_registry->modules()->isNotEmpty() 124 | ) { 125 | return false; 126 | } 127 | 128 | $this->title('Welcome'); 129 | 130 | $message = "You're about to create your first module in the {$this->module_namespace} " 131 | .'namespace. This is the default namespace, and will work for many use-cases. However, ' 132 | .'if you ever choose to extract a module into its own package, you will ' 133 | ."likely want to use a custom namespace (like your organization name).\n\n" 134 | .'If you would like to use a custom namespace, please publish the config ' 135 | ."and customize it first. You can do this by calling:\n\n" 136 | .'php artisan vendor:publish --tag=modular-config'; 137 | 138 | $width = min((new Terminal())->getWidth(), 100) - 1; 139 | $messages = explode(PHP_EOL, wordwrap($message, $width, PHP_EOL)); 140 | foreach ($messages as $message) { 141 | $this->line(" {$message}"); 142 | } 143 | 144 | return $this->confirm('Would you like to cancel and configure your module namespace first?', true); 145 | } 146 | 147 | protected function ensureModulesDirectoryExists() 148 | { 149 | if (! $this->filesystem->isDirectory($this->base_path)) { 150 | $this->filesystem->makeDirectory($this->base_path, 0777, true); 151 | $this->line(" - Created {$this->base_path}"); 152 | } 153 | } 154 | 155 | protected function writeStubs() 156 | { 157 | $this->title('Creating initial module files'); 158 | 159 | $tests_base = config('app-modules.tests_base', 'Tests\TestCase'); 160 | 161 | $placeholders = [ 162 | 'StubBasePath' => $this->base_path, 163 | 'StubModuleNamespace' => $this->module_namespace, 164 | 'StubComposerNamespace' => $this->composer_namespace, 165 | 'StubModuleNameSingular' => Str::singular($this->module_name), 166 | 'StubModuleNamePlural' => Str::plural($this->module_name), 167 | 'StubModuleName' => $this->module_name, 168 | 'StubClassNamePrefix' => $this->class_name_prefix, 169 | 'StubComposerName' => $this->composer_name, 170 | 'StubMigrationPrefix' => date('Y_m_d_His'), 171 | 'StubFullyQualifiedTestCaseBase' => $tests_base, 172 | 'StubTestCaseBase' => class_basename($tests_base), 173 | ]; 174 | 175 | $search = array_keys($placeholders); 176 | $replace = array_values($placeholders); 177 | 178 | foreach ($this->getStubs() as $destination => $stub_file) { 179 | $contents = file_get_contents($stub_file); 180 | $destination = str_replace($search, $replace, $destination); 181 | $filename = "{$this->base_path}/{$destination}"; 182 | 183 | $output = str_replace($search, $replace, $contents); 184 | 185 | if ($this->filesystem->exists($filename)) { 186 | $this->line(" - Skipping {$destination} (already exists)"); 187 | continue; 188 | } 189 | 190 | $this->filesystem->ensureDirectoryExists($this->filesystem->dirname($filename)); 191 | $this->filesystem->put($filename, $output); 192 | 193 | $this->line(" - Wrote to {$destination}"); 194 | } 195 | 196 | $this->newLine(); 197 | } 198 | 199 | protected function seedersDirectory(): string 200 | { 201 | return version_compare($this->getLaravel()->version(), '8.0.0', '>=') 202 | ? 'seeders' 203 | : 'seeds'; 204 | } 205 | 206 | protected function updateCoreComposerConfig() 207 | { 208 | $this->title('Updating application composer.json file'); 209 | 210 | // We're going to move into the Laravel base directory while 211 | // we're updating the composer file so that we're sure we update 212 | // the correct composer.json file (we'll restore CWD at the end) 213 | $original_working_dir = getcwd(); 214 | chdir($this->laravel->basePath()); 215 | 216 | $json_file = new JsonFile(Factory::getComposerFile()); 217 | $definition = $json_file->read(); 218 | 219 | if (! isset($definition['repositories'])) { 220 | $definition['repositories'] = []; 221 | } 222 | 223 | if (! isset($definition['require'])) { 224 | $definition['require'] = []; 225 | } 226 | 227 | $module_config = [ 228 | 'type' => 'path', 229 | 'url' => str_replace('\\', '/', config('app-modules.modules_directory', 'app-modules')).'/*', 230 | 'options' => [ 231 | 'symlink' => true, 232 | ], 233 | ]; 234 | 235 | $has_changes = false; 236 | 237 | $repository_already_exists = collect($definition['repositories']) 238 | ->contains(function($repository) use ($module_config) { 239 | return $repository['url'] === $module_config['url']; 240 | }); 241 | 242 | if (false === $repository_already_exists) { 243 | $this->line(" - Adding path repository for {$module_config['url']}"); 244 | $has_changes = true; 245 | 246 | if (Arr::isAssoc($definition['repositories'])) { 247 | $definition['repositories'][$this->module_name] = $module_config; 248 | } else { 249 | $definition['repositories'][] = $module_config; 250 | } 251 | } 252 | 253 | if (! isset($definition['require'][$this->composer_name])) { 254 | $this->line(" - Adding require statement for {$this->composer_name}:*"); 255 | $has_changes = true; 256 | 257 | $definition['require']["{$this->composer_namespace}/{$this->module_name}"] = '*'; 258 | $definition['require'] = $this->sortComposerPackages($definition['require']); 259 | } 260 | 261 | if ($has_changes) { 262 | $json_file->write($definition); 263 | $this->line(" - Wrote to {$json_file->getPath()}"); 264 | } else { 265 | $this->line(' - Nothing to update (repository & require entry already exist)'); 266 | } 267 | 268 | chdir($original_working_dir); 269 | 270 | $this->newLine(); 271 | } 272 | 273 | protected function sortComposerPackages(array $packages): array 274 | { 275 | $prefix = function($requirement) { 276 | return preg_replace( 277 | [ 278 | '/^php$/', 279 | '/^hhvm-/', 280 | '/^ext-/', 281 | '/^lib-/', 282 | '/^\D/', 283 | '/^(?!php$|hhvm-|ext-|lib-)/', 284 | ], 285 | [ 286 | '0-$0', 287 | '1-$0', 288 | '2-$0', 289 | '3-$0', 290 | '4-$0', 291 | '5-$0', 292 | ], 293 | $requirement 294 | ); 295 | }; 296 | 297 | uksort($packages, function($a, $b) use ($prefix) { 298 | return strnatcmp($prefix($a), $prefix($b)); 299 | }); 300 | 301 | return $packages; 302 | } 303 | 304 | protected function setUpStyles() 305 | { 306 | $formatter = $this->getOutput()->getFormatter(); 307 | 308 | if (! $formatter->hasStyle('kbd')) { 309 | $formatter->setStyle('kbd', new OutputFormatterStyle('cyan')); 310 | } 311 | } 312 | 313 | protected function title($title) 314 | { 315 | $this->getOutput()->title($title); 316 | } 317 | 318 | public function newLine($count = 1) 319 | { 320 | $this->getOutput()->newLine($count); 321 | } 322 | 323 | protected function getStubs(): array 324 | { 325 | if (is_array($custom_stubs = config('app-modules.stubs'))) { 326 | return $custom_stubs; 327 | } 328 | 329 | $composer_stub = version_compare($this->getLaravel()->version(), '8.0.0', '<') 330 | ? 'composer-stub-v7.json' 331 | : 'composer-stub-latest.json'; 332 | 333 | return [ 334 | 'composer.json' => $this->pathToStub($composer_stub), 335 | 'src/Providers/StubClassNamePrefixServiceProvider.php' => $this->pathToStub('ServiceProvider.php'), 336 | 'tests/Feature/Providers/StubClassNamePrefixServiceProviderTest.php' => $this->pathToStub('ServiceProviderTest.php'), 337 | 'database/migrations/StubMigrationPrefix_set_up_StubModuleName_module.php' => $this->pathToStub('migration.php'), 338 | 'routes/StubModuleName-routes.php' => $this->pathToStub('web-routes.php'), 339 | 'resources/views/index.blade.php' => $this->pathToStub('view.blade.php'), 340 | 'resources/views/create.blade.php' => $this->pathToStub('view.blade.php'), 341 | 'resources/views/show.blade.php' => $this->pathToStub('view.blade.php'), 342 | 'resources/views/edit.blade.php' => $this->pathToStub('view.blade.php'), 343 | 'database/factories/.gitkeep' => $this->pathToStub('.gitkeep'), 344 | 'database/migrations/.gitkeep' => $this->pathToStub('.gitkeep'), 345 | 'database/'.$this->seedersDirectory().'/.gitkeep' => $this->pathToStub('.gitkeep'), 346 | ]; 347 | } 348 | 349 | protected function pathToStub($filename): string 350 | { 351 | return str_replace('\\', '/', dirname(__DIR__, 4))."/stubs/{$filename}"; 352 | } 353 | } 354 | -------------------------------------------------------------------------------- /src/Console/Commands/Make/MakeNotification.php: -------------------------------------------------------------------------------- 1 | module()) { 17 | $name = Str::replaceFirst($module->qualify('Database\\Seeders\\'), '', $name); 18 | return $this->getModularPath($name); 19 | } 20 | 21 | return parent::getPath($name); 22 | } 23 | 24 | protected function replaceNamespace(&$stub, $name) 25 | { 26 | if ($module = $this->module()) { 27 | if (version_compare($this->getLaravel()->version(), '9.6.0', '<')) { 28 | $namespace = $module->qualify('Database\Seeders'); 29 | $stub = str_replace('namespace Database\Seeders;', "namespace {$namespace};", $stub); 30 | } 31 | } 32 | 33 | return parent::replaceNamespace($stub, $name); 34 | } 35 | 36 | protected function rootNamespace() 37 | { 38 | if ($module = $this->module()) { 39 | if (version_compare($this->getLaravel()->version(), '9.6.0', '>=')) { 40 | return $module->qualify('Database\Seeders'); 41 | } 42 | } 43 | 44 | return parent::rootNamespace(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Console/Commands/Make/MakeTest.php: -------------------------------------------------------------------------------- 1 | module()) { 17 | $name = '\\'.Str::replaceFirst($module->namespaces->first(), '', $name); 18 | return $this->getModularPath($name); 19 | } 20 | 21 | return parent::getPath($name); 22 | } 23 | 24 | protected function rootNamespace() 25 | { 26 | if ($module = $this->module()) { 27 | return $module->namespaces->first().'Tests'; 28 | } 29 | 30 | return 'Tests'; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Console/Commands/Make/Modularize.php: -------------------------------------------------------------------------------- 1 | module(); 15 | 16 | if ($module && false === strpos($rootNamespace, $module->namespaces->first())) { 17 | $find = rtrim($rootNamespace, '\\'); 18 | $replace = rtrim($module->namespaces->first(), '\\'); 19 | $namespace = str_replace($find, $replace, $namespace); 20 | } 21 | 22 | return $namespace; 23 | } 24 | 25 | protected function qualifyClass($name) 26 | { 27 | $name = ltrim($name, '\\/'); 28 | 29 | if ($module = $this->module()) { 30 | if (Str::startsWith($name, $module->namespaces->first())) { 31 | return $name; 32 | } 33 | } 34 | 35 | return parent::qualifyClass($name); 36 | } 37 | 38 | protected function qualifyModel(string $model) 39 | { 40 | if ($module = $this->module()) { 41 | $model = str_replace('/', '\\', ltrim($model, '\\/')); 42 | 43 | if (Str::startsWith($model, $module->namespace())) { 44 | return $model; 45 | } 46 | 47 | return $module->qualify('Models\\'.$model); 48 | } 49 | 50 | return parent::qualifyModel($model); 51 | } 52 | 53 | protected function getPath($name) 54 | { 55 | if ($module = $this->module()) { 56 | $name = Str::replaceFirst($module->namespaces->first(), '', $name); 57 | } 58 | 59 | $path = parent::getPath($name); 60 | 61 | if ($module) { 62 | // Set up our replacements as a [find -> replace] array 63 | $replacements = [ 64 | $this->laravel->path() => $module->namespaces->keys()->first(), 65 | $this->laravel->basePath('tests/Tests') => $module->path('tests'), 66 | $this->laravel->databasePath() => $module->path('database'), 67 | ]; 68 | 69 | // Normalize all our paths for compatibility's sake 70 | $normalize = function($path) { 71 | return rtrim($path, '/').'/'; 72 | }; 73 | 74 | $find = array_map($normalize, array_keys($replacements)); 75 | $replace = array_map($normalize, array_values($replacements)); 76 | 77 | // And finally apply the replacements 78 | $path = str_replace($find, $replace, $path); 79 | } 80 | 81 | return $path; 82 | } 83 | 84 | public function call($command, array $arguments = []) 85 | { 86 | // Pass the --module flag on to subsequent commands 87 | if ($module = $this->option('module')) { 88 | $arguments['--module'] = $module; 89 | } 90 | 91 | return $this->runCommand($command, $arguments, $this->output); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Console/Commands/Modularize.php: -------------------------------------------------------------------------------- 1 | option('module')) { 15 | $registry = $this->getLaravel()->make(ModuleRegistry::class); 16 | 17 | if ($module = $registry->module($name)) { 18 | return $module; 19 | } 20 | 21 | throw new InvalidOptionException(sprintf('The "%s" module does not exist.', $name)); 22 | } 23 | 24 | return null; 25 | } 26 | 27 | protected function configure() 28 | { 29 | parent::configure(); 30 | 31 | $this->getDefinition()->addOption( 32 | new InputOption( 33 | '--module', 34 | null, 35 | InputOption::VALUE_REQUIRED, 36 | 'Run inside an application module' 37 | ) 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Console/Commands/ModulesCache.php: -------------------------------------------------------------------------------- 1 | call(ModulesClear::class); 21 | 22 | $export = $registry->modules() 23 | ->map(function(ModuleConfig $module_config) { 24 | return $module_config->toArray(); 25 | }) 26 | ->toArray(); 27 | 28 | $cache_path = $registry->getCachePath(); 29 | $cache_contents = 'put($cache_path, $cache_contents); 32 | 33 | try { 34 | require $cache_path; 35 | } catch (Throwable $e) { 36 | $filesystem->delete($cache_path); 37 | throw new LogicException('Unable to cache module configuration.', 0, $e); 38 | } 39 | 40 | $this->info('Modules cached successfully!'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Console/Commands/ModulesClear.php: -------------------------------------------------------------------------------- 1 | delete($registry->getCachePath()); 18 | $this->info('Module cache cleared!'); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Console/Commands/ModulesList.php: -------------------------------------------------------------------------------- 1 | modules() 21 | ->map(function(ModuleConfig $config) use (&$namespace_title) { 22 | $namespaces = $config->namespaces->map(function($namespace) { 23 | return rtrim($namespace, '\\'); 24 | }); 25 | 26 | if ($config->namespaces->count() > 1) { 27 | $namespace_title = 'Namespaces'; 28 | } 29 | 30 | return [ 31 | $config->name, 32 | Str::after(str_replace('\\', '/', $config->base_path), str_replace('\\', '/', $this->laravel->basePath()).'/'), 33 | $namespaces->implode(', '), 34 | ]; 35 | }) 36 | ->toArray(); 37 | 38 | $count = $registry->modules()->count(); 39 | $this->line('You have '.$count.' '.Str::plural('module', $count).' installed.'); 40 | $this->line(''); 41 | 42 | $this->table(['Module', 'Path', $namespace_title], $table); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Console/Commands/ModulesSync.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 34 | $this->registry = $registry; 35 | 36 | $this->updatePhpUnit(); 37 | 38 | if (true !== $this->option('no-phpstorm')) { 39 | $this->updatePhpStormConfig(); 40 | } 41 | } 42 | 43 | protected function updatePhpUnit(): void 44 | { 45 | $config_path = $this->getLaravel()->basePath('phpunit.xml'); 46 | 47 | if (! $this->filesystem->exists($config_path)) { 48 | $this->warn('No phpunit.xml file found. Skipping PHPUnit configuration.'); 49 | return; 50 | } 51 | 52 | $modules_directory = config('app-modules.modules_directory', 'app-modules'); 53 | 54 | $config = simplexml_load_string($this->filesystem->get($config_path)); 55 | 56 | $existing_nodes = $config->xpath("//phpunit//testsuites//testsuite//directory[text()='./{$modules_directory}/*/tests']"); 57 | 58 | if (count($existing_nodes)) { 59 | $this->info('Modules test suite already exists in phpunit.xml'); 60 | return; 61 | } 62 | 63 | $testsuites = $config->xpath('//phpunit//testsuites'); 64 | if (! count($testsuites)) { 65 | $this->error('Cannot find node in phpunit.xml file. Skipping PHPUnit configuration.'); 66 | return; 67 | } 68 | 69 | $testsuite = $testsuites[0]->addChild('testsuite'); 70 | $testsuite->addAttribute('name', 'Modules'); 71 | 72 | $directory = $testsuite->addChild('directory'); 73 | $directory->addAttribute('suffix', 'Test.php'); 74 | $directory[0] = "./{$modules_directory}/*/tests"; 75 | 76 | $this->filesystem->put($config_path, $config->asXML()); 77 | $this->info('Added "Modules" PHPUnit test suite.'); 78 | } 79 | 80 | protected function updatePhpStormConfig(): void 81 | { 82 | $this->updatePhpStormLaravelPlugin(); 83 | $this->updatePhpStormPhpConfig(); 84 | $this->updatePhpStormWorkspaceConfig(); 85 | $this->updatePhpStormProjectIml(); 86 | } 87 | 88 | protected function updatePhpStormLaravelPlugin(): void 89 | { 90 | $config_path = $this->getLaravel()->basePath('.idea/laravel-plugin.xml'); 91 | $writer = new LaravelConfigWriter($config_path, $this->registry); 92 | 93 | if ($writer->handle()) { 94 | $this->info('Updated PhpStorm/Laravel Plugin config file...'); 95 | } else { 96 | $this->info('Did not find/update PhpStorm/Laravel Plugin config.'); 97 | if ($this->getOutput()->isVerbose()) { 98 | $this->warn($writer->last_error); 99 | } 100 | } 101 | } 102 | 103 | protected function updatePhpStormPhpConfig(): void 104 | { 105 | $config_path = $this->getLaravel()->basePath('.idea/php.xml'); 106 | $writer = new PhpFrameworkWriter($config_path, $this->registry); 107 | 108 | if ($writer->handle()) { 109 | $this->info('Updated PhpStorm PHP config file...'); 110 | } else { 111 | $this->info('Did not find/update PhpStorm PHP config.'); 112 | if ($this->getOutput()->isVerbose()) { 113 | $this->warn($writer->last_error); 114 | } 115 | } 116 | } 117 | 118 | protected function updatePhpStormWorkspaceConfig(): void 119 | { 120 | $config_path = $this->getLaravel()->basePath('.idea/workspace.xml'); 121 | $writer = new WorkspaceWriter($config_path, $this->registry); 122 | 123 | if ($writer->handle()) { 124 | $this->info('Updated PhpStorm workspace library roots...'); 125 | } else { 126 | $this->info('Did not find/update PhpStorm workspace config.'); 127 | if ($this->getOutput()->isVerbose()) { 128 | $this->warn($writer->last_error); 129 | } 130 | } 131 | } 132 | 133 | protected function updatePhpStormProjectIml(): void 134 | { 135 | $idea_directory = $this->getLaravel()->basePath('.idea/'); 136 | if (! $this->filesystem->isDirectory($idea_directory)) { 137 | return; 138 | } 139 | 140 | FinderCollection::forFiles() 141 | ->in($idea_directory) 142 | ->name('*.iml') 143 | ->first(function(SplFileInfo $file) { 144 | $config_path = $file->getPathname(); 145 | $writer = new ProjectImlWriter($config_path, $this->registry); 146 | 147 | if ($writer->handle()) { 148 | $this->info("Updated PhpStorm project source folders in '{$file->getBasename()}'"); 149 | return true; 150 | } 151 | 152 | $this->info("Could not update PhpStorm project source folders in '{$file->getBasename()}'"); 153 | 154 | if ($this->getOutput()->isVerbose()) { 155 | $this->warn($writer->last_error); 156 | } 157 | 158 | return false; 159 | }); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/Exceptions/CannotFindModuleForPathException.php: -------------------------------------------------------------------------------- 1 | base_path = $module_registry->getModulesPath(); 16 | } 17 | 18 | public function commandFileFinder(): FinderCollection 19 | { 20 | return FinderCollection::forFiles() 21 | ->name('*.php') 22 | ->inOrEmpty($this->base_path.'/*/src/Console/Commands'); 23 | } 24 | 25 | public function factoryDirectoryFinder(): FinderCollection 26 | { 27 | return FinderCollection::forDirectories() 28 | ->depth(0) 29 | ->name('factories') 30 | ->inOrEmpty($this->base_path.'/*/database/'); 31 | } 32 | 33 | public function migrationDirectoryFinder(): FinderCollection 34 | { 35 | return FinderCollection::forDirectories() 36 | ->depth(0) 37 | ->name('migrations') 38 | ->inOrEmpty($this->base_path.'/*/database/'); 39 | } 40 | 41 | public function modelFileFinder(): FinderCollection 42 | { 43 | return FinderCollection::forFiles() 44 | ->name('*.php') 45 | ->inOrEmpty($this->base_path.'/*/src/Models'); 46 | } 47 | 48 | public function bladeComponentFileFinder(): FinderCollection 49 | { 50 | return FinderCollection::forFiles() 51 | ->name('*.php') 52 | ->inOrEmpty($this->base_path.'/*/src/View/Components'); 53 | } 54 | 55 | public function bladeComponentDirectoryFinder(): FinderCollection 56 | { 57 | return FinderCollection::forDirectories() 58 | ->name('Components') 59 | ->inOrEmpty($this->base_path.'/*/src/View'); 60 | } 61 | 62 | public function routeFileFinder(): FinderCollection 63 | { 64 | return FinderCollection::forFiles() 65 | ->depth(0) 66 | ->name('*.php') 67 | ->sortByName() 68 | ->inOrEmpty($this->base_path.'/*/routes'); 69 | } 70 | 71 | public function viewDirectoryFinder(): FinderCollection 72 | { 73 | return FinderCollection::forDirectories() 74 | ->depth(0) 75 | ->name('views') 76 | ->inOrEmpty($this->base_path.'/*/resources/'); 77 | } 78 | 79 | public function langDirectoryFinder(): FinderCollection 80 | { 81 | return FinderCollection::forDirectories() 82 | ->depth(0) 83 | ->name('lang') 84 | ->inOrEmpty($this->base_path.'/*/resources/'); 85 | } 86 | 87 | public function listenerDirectoryFinder(): FinderCollection 88 | { 89 | return FinderCollection::forDirectories() 90 | ->name('Listeners') 91 | ->inOrEmpty($this->base_path.'/*/src'); 92 | } 93 | 94 | public function livewireComponentFileFinder(): FinderCollection 95 | { 96 | $directory = $this->base_path.'/*/src'; 97 | 98 | if (str_contains(config('livewire.class_namespace'), '\\Http\\')) { 99 | $directory .= '/Http'; 100 | } 101 | 102 | $directory .= '/Livewire'; 103 | 104 | return FinderCollection::forFiles() 105 | ->name('*.php') 106 | ->inOrEmpty($directory); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Support/DatabaseFactoryHelper.php: -------------------------------------------------------------------------------- 1 | =')) { 23 | Factory::flushState(); 24 | } else { 25 | $this->unsetProperty(Factory::class, 'modelNameResolver'); 26 | $this->unsetProperty(Factory::class, 'modelNameResolvers'); 27 | $this->unsetProperty(Factory::class, 'factoryNameResolver'); 28 | } 29 | } 30 | 31 | public function modelNameResolver(): Closure 32 | { 33 | return function(Factory $factory) { 34 | if ($module = $this->registry->moduleForClass(get_class($factory))) { 35 | return (string) Str::of(get_class($factory)) 36 | ->replaceFirst($module->qualify($this->namespace()), '') 37 | ->replaceLast('Factory', '') 38 | ->prepend($module->qualify('Models'), '\\'); 39 | } 40 | 41 | // Temporarily disable the modular resolver if we're not in a module 42 | try { 43 | $this->unsetProperty(Factory::class, 'modelNameResolver'); 44 | $this->unsetProperty(Factory::class, 'modelNameResolvers'); 45 | return $factory->modelName(); 46 | } finally { 47 | Factory::guessModelNamesUsing($this->modelNameResolver()); 48 | } 49 | }; 50 | } 51 | 52 | public function factoryNameResolver(): Closure 53 | { 54 | return function($model_name) { 55 | if ($module = $this->registry->moduleForClass($model_name)) { 56 | $model_name = Str::startsWith($model_name, $module->qualify('Models\\')) 57 | ? Str::after($model_name, $module->qualify('Models\\')) 58 | : Str::after($model_name, $module->namespace()); 59 | 60 | return $module->qualify($this->namespace().$model_name.'Factory'); 61 | } 62 | 63 | // Temporarily disable the modular resolver if we're not in a module 64 | try { 65 | $this->unsetProperty(Factory::class, 'factoryNameResolver'); 66 | return Factory::resolveFactoryName($model_name); 67 | } finally { 68 | Factory::guessFactoryNamesUsing($this->factoryNameResolver()); 69 | } 70 | }; 71 | } 72 | 73 | /** 74 | * Because Factory::$namespace is protected, we need to access it via reflection. 75 | */ 76 | public function namespace(): string 77 | { 78 | return $this->namespace ??= $this->getProperty(Factory::class, 'namespace'); 79 | } 80 | 81 | protected function getProperty($target, $property) 82 | { 83 | $reflection = new ReflectionClass($target); 84 | return $reflection->getStaticPropertyValue($property); 85 | } 86 | 87 | protected function unsetProperty($target, $property): void 88 | { 89 | $reflection = new ReflectionClass($target); 90 | if ($reflection->hasProperty($property)) { 91 | $reflection->setStaticPropertyValue($property, $reflection->getProperty($property)->getDefaultValue()); 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Support/DiscoverEvents.php: -------------------------------------------------------------------------------- 1 | getRealPath())) { 13 | return $module->pathToFullyQualifiedClassName($file->getPathname()); 14 | } 15 | 16 | return parent::classFromFile($file, $basePath); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Support/Facades/Modules.php: -------------------------------------------------------------------------------- 1 | files()); 23 | } 24 | 25 | public static function forDirectories(): self 26 | { 27 | return new static(Finder::create()->directories()); 28 | } 29 | 30 | public function __construct( 31 | protected ?Finder $finder = null, 32 | protected ?LazyCollection $collection = null, 33 | ) { 34 | if (! $this->finder && ! $this->collection) { 35 | $this->collection = new LazyCollection(); 36 | } 37 | } 38 | 39 | public function inOrEmpty(string|array $dirs): static 40 | { 41 | try { 42 | return $this->in($dirs); 43 | } catch (DirectoryNotFoundException) { 44 | return new static(); 45 | } 46 | } 47 | 48 | public function __call($name, $arguments) 49 | { 50 | $result = $this->forwardCallTo($this->forwardCallTargetForMethod($name), $name, $arguments); 51 | 52 | if ($result instanceof Finder) { 53 | return new static($result); 54 | } 55 | 56 | if ($result instanceof LazyCollection) { 57 | return new static($this->finder, $result); 58 | } 59 | 60 | return $result; 61 | } 62 | 63 | protected function forwardCallTargetForMethod(string $name): Finder|LazyCollection 64 | { 65 | if (is_callable([$this->finder, $name]) && ! in_array($name, static::PREFER_COLLECTION_METHODS)) { 66 | return $this->finder; 67 | } 68 | 69 | return $this->forwardCollection(); 70 | } 71 | 72 | protected function forwardCollection(): LazyCollection 73 | { 74 | return $this->collection ??= new LazyCollection(function() { 75 | foreach ($this->finder as $key => $value) { 76 | yield $key => $value; 77 | } 78 | }); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Support/ModularEventServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->booting(function() { 21 | $events = $this->getEvents(); 22 | $provider = Arr::first($this->app->getProviders(EventServiceProvider::class)); 23 | 24 | if (! $provider || empty($events)) { 25 | return; 26 | } 27 | 28 | $listen = new ReflectionProperty($provider, 'listen'); 29 | $listen->setAccessible(true); 30 | $listen->setValue($provider, array_merge_recursive($listen->getValue($provider), $events)); 31 | }); 32 | } 33 | 34 | public function getEvents(): array 35 | { 36 | // If events are cached, or Modular event discovery is disabled, then we'll 37 | // just let the normal event service provider handle all the event loading. 38 | if ($this->app->eventsAreCached() || ! $this->shouldDiscoverEvents()) { 39 | return []; 40 | } 41 | 42 | return $this->discoverEvents(); 43 | } 44 | 45 | public function shouldDiscoverEvents(): bool 46 | { 47 | return config('app-modules.should_discover_events') 48 | ?? $this->appIsConfiguredToDiscoverEvents(); 49 | } 50 | 51 | public function discoverEvents() 52 | { 53 | $modules = $this->app->make(ModuleRegistry::class); 54 | 55 | return $this->app->make(AutoDiscoveryHelper::class) 56 | ->listenerDirectoryFinder() 57 | ->map(fn(SplFileInfo $directory) => $directory->getPathname()) 58 | ->reduce(function($discovered, string $directory) use ($modules) { 59 | $module = $modules->moduleForPath($directory); 60 | return array_merge_recursive( 61 | $discovered, 62 | DiscoverEvents::within($directory, $module->path('src')) 63 | ); 64 | }, []); 65 | } 66 | 67 | public function appIsConfiguredToDiscoverEvents(): bool 68 | { 69 | return collect($this->app->getProviders(EventServiceProvider::class)) 70 | ->filter(fn(EventServiceProvider $provider) => $provider::class === EventServiceProvider::class 71 | || str_starts_with(get_class($provider), $this->app->getNamespace())) 72 | ->contains(fn(EventServiceProvider $provider) => $provider->shouldDiscoverEvents()); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Support/ModularizedCommandsServiceProvider.php: -------------------------------------------------------------------------------- 1 | MakeCast::class, 40 | 'command.controller.make' => MakeController::class, 41 | 'command.console.make' => MakeCommand::class, 42 | 'command.channel.make' => MakeChannel::class, 43 | 'command.event.make' => MakeEvent::class, 44 | 'command.exception.make' => MakeException::class, 45 | 'command.factory.make' => MakeFactory::class, 46 | 'command.job.make' => MakeJob::class, 47 | 'command.listener.make' => MakeListener::class, 48 | 'command.mail.make' => MakeMail::class, 49 | 'command.middleware.make' => MakeMiddleware::class, 50 | 'command.model.make' => MakeModel::class, 51 | 'command.notification.make' => MakeNotification::class, 52 | 'command.observer.make' => MakeObserver::class, 53 | 'command.policy.make' => MakePolicy::class, 54 | 'command.provider.make' => MakeProvider::class, 55 | 'command.request.make' => MakeRequest::class, 56 | 'command.resource.make' => MakeResource::class, 57 | 'command.rule.make' => MakeRule::class, 58 | 'command.seeder.make' => MakeSeeder::class, 59 | 'command.test.make' => MakeTest::class, 60 | 'command.component.make' => MakeComponent::class, 61 | 'command.seed' => SeedCommand::class, 62 | ]; 63 | 64 | public function register(): void 65 | { 66 | // Register our overrides via the "booted" event to ensure that we override 67 | // the default behavior regardless of which service provider happens to be 68 | // bootstrapped first (this mostly matters for Livewire). 69 | $this->app->booted(function() { 70 | Artisan::starting(function(Application $artisan) { 71 | $this->registerMakeCommandOverrides(); 72 | $this->registerMigrationCommandOverrides(); 73 | $this->registerLivewireOverrides($artisan); 74 | }); 75 | }); 76 | } 77 | 78 | protected function registerMakeCommandOverrides() 79 | { 80 | foreach ($this->overrides as $alias => $class_name) { 81 | $this->app->singleton($alias, $class_name); 82 | $this->app->singleton(get_parent_class($class_name), $class_name); 83 | } 84 | } 85 | 86 | protected function registerMigrationCommandOverrides() 87 | { 88 | // Laravel 8 89 | $this->app->singleton('command.migrate.make', function($app) { 90 | return new MakeMigration($app['migration.creator'], $app['composer']); 91 | }); 92 | 93 | // Laravel 9 94 | $this->app->singleton(OriginalMakeMigrationCommand::class, function($app) { 95 | return new MakeMigration($app['migration.creator'], $app['composer']); 96 | }); 97 | } 98 | 99 | protected function registerLivewireOverrides(Artisan $artisan) 100 | { 101 | // Don't register commands if Livewire isn't installed 102 | if (! class_exists(Livewire\MakeCommand::class)) { 103 | return; 104 | } 105 | 106 | // Replace the resolved command with our subclass 107 | $artisan->resolveCommands([MakeLivewire::class]); 108 | 109 | // Ensure that if 'make:livewire' or 'livewire:make' is resolved from the container 110 | // in the future, our subclass is used instead 111 | $this->app->extend(Livewire\MakeCommand::class, function() { 112 | return new MakeLivewire(); 113 | }); 114 | $this->app->extend(Livewire\MakeLivewireCommand::class, function() { 115 | return new MakeLivewire(); 116 | }); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Support/ModuleConfig.php: -------------------------------------------------------------------------------- 1 | getContents(), true, 16, JSON_THROW_ON_ERROR); 18 | 19 | $base_path = rtrim(str_replace('\\', '/', $composer_file->getPath()), '/'); 20 | 21 | $name = basename($base_path); 22 | 23 | $namespaces = Collection::make($composer_config['autoload']['psr-4'] ?? []) 24 | ->mapWithKeys(function($src, $namespace) use ($base_path) { 25 | $path = $base_path.'/'.$src; 26 | return [$path => $namespace]; 27 | }); 28 | 29 | return new static($name, $base_path, $namespaces); 30 | } 31 | 32 | public function __construct( 33 | public string $name, 34 | public string $base_path, 35 | ?Collection $namespaces = null 36 | ) { 37 | $this->namespaces = $namespaces ?? new Collection(); 38 | } 39 | 40 | public function path(string $to = ''): string 41 | { 42 | return rtrim($this->base_path.'/'.$to, '/'); 43 | } 44 | 45 | public function namespace(): string 46 | { 47 | return $this->namespaces->first(); 48 | } 49 | 50 | public function qualify(string $class_name): string 51 | { 52 | return $this->namespace().ltrim($class_name, '\\'); 53 | } 54 | 55 | public function pathToFullyQualifiedClassName(string $path): string 56 | { 57 | // Handle Windows-style paths 58 | $path = str_replace('\\', '/', $path); 59 | 60 | foreach ($this->namespaces as $namespace_path => $namespace) { 61 | if (str_starts_with($path, $namespace_path)) { 62 | $relative_path = Str::after($path, $namespace_path); 63 | return $namespace.$this->formatPathAsNamespace($relative_path); 64 | } 65 | } 66 | 67 | throw new RuntimeException("Unable to infer qualified class name for '{$path}'"); 68 | } 69 | 70 | public function toArray(): array 71 | { 72 | return [ 73 | 'name' => $this->name, 74 | 'base_path' => $this->base_path, 75 | 'namespaces' => $this->namespaces->toArray(), 76 | ]; 77 | } 78 | 79 | protected function formatPathAsNamespace(string $path): string 80 | { 81 | $path = trim($path, '/'); 82 | 83 | $replacements = [ 84 | '/' => '\\', 85 | '.php' => '', 86 | ]; 87 | 88 | return str_replace( 89 | array_keys($replacements), 90 | array_values($replacements), 91 | $path 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Support/ModuleRegistry.php: -------------------------------------------------------------------------------- 1 | modules_path; 23 | } 24 | 25 | public function getCachePath(): string 26 | { 27 | return $this->cache_path; 28 | } 29 | 30 | public function module(?string $name = null): ?ModuleConfig 31 | { 32 | // We want to allow for gracefully handling empty/null names 33 | return $name 34 | ? $this->modules()->get($name) 35 | : null; 36 | } 37 | 38 | public function moduleForPath(string $path): ?ModuleConfig 39 | { 40 | return $this->module($this->extractModuleNameFromPath($path)); 41 | } 42 | 43 | public function moduleForPathOrFail(string $path): ModuleConfig 44 | { 45 | if ($module = $this->moduleForPath($path)) { 46 | return $module; 47 | } 48 | 49 | throw new CannotFindModuleForPathException($path); 50 | } 51 | 52 | public function moduleForClass(string $fqcn): ?ModuleConfig 53 | { 54 | return $this->modules()->first(function(ModuleConfig $module) use ($fqcn) { 55 | foreach ($module->namespaces as $namespace) { 56 | if (Str::startsWith($fqcn, $namespace)) { 57 | return true; 58 | } 59 | } 60 | 61 | return false; 62 | }); 63 | } 64 | 65 | public function modules(): Collection 66 | { 67 | return $this->modules ??= $this->loadModules(); 68 | } 69 | 70 | public function reload(): Collection 71 | { 72 | $this->modules = null; 73 | 74 | return $this->loadModules(); 75 | } 76 | 77 | protected function loadModules(): Collection 78 | { 79 | if (file_exists($this->cache_path)) { 80 | return Collection::make(require $this->cache_path) 81 | ->mapWithKeys(function(array $cached) { 82 | $config = new ModuleConfig($cached['name'], $cached['base_path'], new Collection($cached['namespaces'])); 83 | return [$config->name => $config]; 84 | }); 85 | } 86 | 87 | if (! is_dir($this->modules_path)) { 88 | return new Collection(); 89 | } 90 | 91 | return FinderCollection::forFiles() 92 | ->depth('== 1') 93 | ->name('composer.json') 94 | ->in($this->modules_path) 95 | ->collect() 96 | ->mapWithKeys(function(SplFileInfo $path) { 97 | $config = ModuleConfig::fromComposerFile($path); 98 | return [$config->name => $config]; 99 | }); 100 | } 101 | 102 | protected function extractModuleNameFromPath(string $path): string 103 | { 104 | // Handle Windows-style paths 105 | $path = str_replace('\\', '/', $path); 106 | 107 | // If the modules directory is symlinked, we may get two paths that are actually 108 | // in the same directory, but have different prefixes. This helps resolve that. 109 | if (Str::startsWith($path, $this->modules_path)) { 110 | $path = trim(Str::after($path, $this->modules_path), '/'); 111 | } elseif (Str::startsWith($path, $modules_real_path = str_replace('\\', '/', realpath($this->modules_path)))) { 112 | $path = trim(Str::after($path, $modules_real_path), '/'); 113 | } 114 | 115 | return explode('/', $path)[0]; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Support/PhpStorm/ConfigWriter.php: -------------------------------------------------------------------------------- 1 | config_path = $config_path; 31 | $this->module_registry = $module_registry; 32 | } 33 | 34 | public function handle(): bool 35 | { 36 | if (! $this->checkConfigFilePermissions()) { 37 | return false; 38 | } 39 | 40 | return $this->write(); 41 | } 42 | 43 | protected function checkConfigFilePermissions(): bool 44 | { 45 | if (! is_readable($this->config_path) || ! is_writable($this->config_path)) { 46 | return $this->error("Unable to find or read: '{$this->config_path}'"); 47 | } 48 | 49 | if (! is_writable($this->config_path)) { 50 | return $this->error("Config file is not writable: '{$this->config_path}'"); 51 | } 52 | 53 | return true; 54 | } 55 | 56 | protected function error(string $message): bool 57 | { 58 | $this->last_error = $message; 59 | return false; 60 | } 61 | 62 | protected function formatXml(SimpleXMLElement $xml): string 63 | { 64 | $dom = new DOMDocument('1.0', 'UTF-8'); 65 | $dom->formatOutput = true; 66 | $dom->preserveWhiteSpace = false; 67 | $dom->loadXML($xml->asXML()); 68 | 69 | $xml = $dom->saveXML(); 70 | $xml = preg_replace('~(\S)/>\s*$~m', '$1 />', $xml); 71 | 72 | return $xml; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Support/PhpStorm/LaravelConfigWriter.php: -------------------------------------------------------------------------------- 1 | getNormalizedPluginConfig(); 13 | $template_paths = $plugin_config->xpath('//templatePath'); 14 | 15 | // Clean up template paths to prevent duplicates 16 | foreach ($template_paths as $template_path_key => $existing) { 17 | if (null !== $this->module_registry->module((string) $existing['namespace'])) { 18 | unset($template_paths[$template_path_key][0]); 19 | } 20 | } 21 | 22 | // Now add all modules to the config 23 | $modules_directory = config('app-modules.modules_directory', 'app-modules'); 24 | $list = $plugin_config->xpath('//option[@name="templatePaths"]//list')[0]; 25 | $this->module_registry->modules() 26 | ->sortBy('name') 27 | ->each(function(ModuleConfig $module_config) use ($list, $modules_directory) { 28 | $node = $list->addChild('templatePath'); 29 | $node->addAttribute('namespace', $module_config->name); 30 | $node->addAttribute('path', "{$modules_directory}/{$module_config->name}/resources/views"); 31 | }); 32 | 33 | return false !== file_put_contents($this->config_path, $this->formatXml($plugin_config)); 34 | } 35 | 36 | protected function getNormalizedPluginConfig(): SimpleXMLElement 37 | { 38 | $config = simplexml_load_string(file_get_contents($this->config_path)); 39 | 40 | // Ensure that exists 41 | $component = $config->xpath('//component[@name="LaravelPluginSettings"]'); 42 | if (empty($component)) { 43 | $component = $config->addChild('component'); 44 | $component->addAttribute('name', 'LaravelPluginSettings'); 45 | } else { 46 | $component = $component[0]; 47 | } 48 | 49 | // Ensure that