From 392678e68a9246f25a9824b410c325d5cf09fbd7 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 20 Jan 2026 17:02:28 +0000 Subject: [PATCH] Initial release: Core PHP modular monolith framework - Event-driven architecture with lazy module loading - ModuleScanner, ModuleRegistry, LazyModuleListener for module discovery - 7 lifecycle events: Web, Admin, API, Client, Console, MCP, FrameworkBooted - AdminMenuProvider and ServiceDefinition contracts - Artisan commands: make:mod, make:website, make:plug - Module stubs for rapid scaffolding - Comprehensive test suite with Orchestra Testbench - GitHub Actions CI for PHP 8.2-8.4 / Laravel 11-12 - EUPL-1.2 license Co-Authored-By: Claude Opus 4.5 --- .github/workflows/tests.yml | 43 +++ .gitignore | 7 + CLAUDE.md | 113 +++++++ LICENSE | 287 ++++++++++++++++++ README.md | 227 ++++++++++++++ composer.json | 42 +++ config/core.php | 26 ++ phpunit.xml | 26 ++ src/Core/Console/Commands/MakeModCommand.php | 107 +++++++ src/Core/Console/Commands/MakePlugCommand.php | 73 +++++ .../Console/Commands/MakeWebsiteCommand.php | 113 +++++++ src/Core/CoreServiceProvider.php | 73 +++++ src/Core/Events/AdminPanelBooting.php | 21 ++ src/Core/Events/ApiRoutesRegistering.php | 18 ++ src/Core/Events/ClientRoutesRegistering.php | 23 ++ src/Core/Events/ConsoleBooting.php | 19 ++ src/Core/Events/FrameworkBooted.php | 18 ++ src/Core/Events/LifecycleEvent.php | 180 +++++++++++ src/Core/Events/McpToolsRegistering.php | 36 +++ src/Core/Events/WebRoutesRegistering.php | 19 ++ .../Admin/Contracts/AdminMenuProvider.php | 48 +++ src/Core/LifecycleEventProvider.php | 171 +++++++++++ src/Core/Module/LazyModuleListener.php | 92 ++++++ src/Core/Module/ModuleRegistry.php | 106 +++++++ src/Core/Module/ModuleScanner.php | 157 ++++++++++ .../Service/Contracts/ServiceDefinition.php | 35 +++ stubs/Mod/Example/Boot.php.stub | 44 +++ stubs/Mod/Example/Routes/admin.php.stub | 19 ++ stubs/Mod/Example/Routes/api.php.stub | 19 ++ stubs/Mod/Example/Routes/web.php.stub | 17 ++ stubs/Mod/Example/config.php.stub | 13 + stubs/Plug/Example/Boot.php.stub | 33 ++ stubs/Website/Example/Boot.php.stub | 36 +++ tests/Feature/LazyModuleListenerTest.php | 84 +++++ tests/Feature/LifecycleEventsTest.php | 136 +++++++++ tests/Feature/ModuleRegistryTest.php | 72 +++++ tests/Feature/ModuleScannerTest.php | 56 ++++ tests/Fixtures/Mod/Example/Boot.php | 19 ++ tests/TestCase.php | 30 ++ 39 files changed, 2658 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/core.php create mode 100644 phpunit.xml create mode 100644 src/Core/Console/Commands/MakeModCommand.php create mode 100644 src/Core/Console/Commands/MakePlugCommand.php create mode 100644 src/Core/Console/Commands/MakeWebsiteCommand.php create mode 100644 src/Core/CoreServiceProvider.php create mode 100644 src/Core/Events/AdminPanelBooting.php create mode 100644 src/Core/Events/ApiRoutesRegistering.php create mode 100644 src/Core/Events/ClientRoutesRegistering.php create mode 100644 src/Core/Events/ConsoleBooting.php create mode 100644 src/Core/Events/FrameworkBooted.php create mode 100644 src/Core/Events/LifecycleEvent.php create mode 100644 src/Core/Events/McpToolsRegistering.php create mode 100644 src/Core/Events/WebRoutesRegistering.php create mode 100644 src/Core/Front/Admin/Contracts/AdminMenuProvider.php create mode 100644 src/Core/LifecycleEventProvider.php create mode 100644 src/Core/Module/LazyModuleListener.php create mode 100644 src/Core/Module/ModuleRegistry.php create mode 100644 src/Core/Module/ModuleScanner.php create mode 100644 src/Core/Service/Contracts/ServiceDefinition.php create mode 100644 stubs/Mod/Example/Boot.php.stub create mode 100644 stubs/Mod/Example/Routes/admin.php.stub create mode 100644 stubs/Mod/Example/Routes/api.php.stub create mode 100644 stubs/Mod/Example/Routes/web.php.stub create mode 100644 stubs/Mod/Example/config.php.stub create mode 100644 stubs/Plug/Example/Boot.php.stub create mode 100644 stubs/Website/Example/Boot.php.stub create mode 100644 tests/Feature/LazyModuleListenerTest.php create mode 100644 tests/Feature/LifecycleEventsTest.php create mode 100644 tests/Feature/ModuleRegistryTest.php create mode 100644 tests/Feature/ModuleScannerTest.php create mode 100644 tests/Fixtures/Mod/Example/Boot.php create mode 100644 tests/TestCase.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..cbafd11 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,43 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + tests: + runs-on: ubuntu-latest + + strategy: + fail-fast: true + matrix: + php: [8.2, 8.3, 8.4] + laravel: [11.*, 12.*] + exclude: + - php: 8.2 + laravel: 12.* + + name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip + coverage: none + + - name: Install dependencies + env: + LARAVEL_VERSION: ${{ matrix.laravel }} + run: | + composer require "laravel/framework:${LARAVEL_VERSION}" --no-interaction --no-update + composer update --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e25776 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/vendor/ +/.phpunit.cache/ +composer.lock +.DS_Store +.idea/ +*.swp +*.swo diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fc45630 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# Core PHP Framework + +A modular monolith framework for Laravel. This is the open-source foundation extracted from Host Hub. + +## Quick Reference + +```bash +composer test # Run tests +composer install # Install dependencies +``` + +## Architecture + +### Event-Driven Module Loading + +1. **ModuleScanner** scans directories for `Boot.php` files with `$listens` arrays +2. **ModuleRegistry** wires lazy listeners for each event-module pair +3. **LazyModuleListener** defers module instantiation until events fire +4. **LifecycleEventProvider** fires events and processes collected requests + +### Lifecycle Events + +Located in `src/Core/Events/`: + +- `WebRoutesRegistering` - public web routes +- `AdminPanelBooting` - admin panel +- `ApiRoutesRegistering` - REST API +- `ClientRoutesRegistering` - authenticated client routes +- `ConsoleBooting` - artisan commands +- `McpToolsRegistering` - MCP tools +- `FrameworkBooted` - late initialisation + +### Key Classes + +| Class | Location | Purpose | +|-------|----------|---------| +| `CoreServiceProvider` | `src/Core/` | Package entry point | +| `LifecycleEventProvider` | `src/Core/` | Fires events, processes requests | +| `ModuleScanner` | `src/Core/Module/` | Scans for `$listens` declarations | +| `ModuleRegistry` | `src/Core/Module/` | Wires lazy listeners | +| `LazyModuleListener` | `src/Core/Module/` | Deferred module instantiation | + +### Contracts + +- `AdminMenuProvider` - admin navigation interface +- `ServiceDefinition` - SaaS service registration + +## File Structure + +``` +src/Core/ +├── CoreServiceProvider.php # Package entry +├── LifecycleEventProvider.php # Event firing +├── Events/ # Lifecycle events +│ ├── LifecycleEvent.php # Base class +│ ├── WebRoutesRegistering.php +│ ├── AdminPanelBooting.php +│ └── ... +├── Module/ # Module system +│ ├── ModuleScanner.php +│ ├── ModuleRegistry.php +│ └── LazyModuleListener.php +├── Front/ # Frontage contracts +│ └── Admin/Contracts/ +│ └── AdminMenuProvider.php +├── Service/Contracts/ +│ └── ServiceDefinition.php +└── Console/Commands/ # Artisan commands + ├── MakeModCommand.php + ├── MakeWebsiteCommand.php + └── MakePlugCommand.php +``` + +## Module Pattern + +```php + 'onWebRoutes', + ]; + + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->views('example', __DIR__.'/Views'); + $event->routes(fn () => require __DIR__.'/Routes/web.php'); + } +} +``` + +## Namespacing + +Default namespace detection: +- `/Core` paths → `Core\` namespace +- `/Mod` paths → `Mod\` namespace +- `/Website` paths → `Website\` namespace +- `/Plug` paths → `Plug\` namespace + +Custom mapping via `ModuleScanner::setNamespaceMap()`. + +## Testing + +Tests use Orchestra Testbench. Fixtures in `tests/Fixtures/`. + +## License + +EUPL-1.2 (copyleft, GPL-compatible). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ba61a9d --- /dev/null +++ b/LICENSE @@ -0,0 +1,287 @@ +EUROPEAN UNION PUBLIC LICENCE v. 1.2 +EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the 'EUPL') applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + +Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- 'The Licence': this Licence. + +- 'The Original Work': the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- 'Derivative Works': the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- 'The Work': the Original Work or its Derivative Works. + +- 'The Source Code': the human-readable form of the Work which is the most + convenient for people to study and modify. + +- 'The Executable Code': any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- 'The Licensor': the natural or legal person that distributes or communicates + the Work under the Licence. + +- 'Contributor(s)': any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- 'The Licensee' or 'You': any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- 'Distribution' or 'Communication': any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating 'EUPL v. 1.2 only'. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, 'Compatible +Licence' refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +'bugs' inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an 'as is' basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no circumstances be liable for any direct or +indirect, material or moral, damages of any kind, arising out of the Licence or +of the use of the Work, including without limitation, damages for loss of +goodwill, work stoppage, computer failure or malfunction, loss of data or any +commercial damage, even if the Licensor has been advised of the possibility of +such damage. However, the Licensor will be liable under statutory product +liability laws as far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon 'I agree' +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +'Compatible Licences' according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bdfb3a9 --- /dev/null +++ b/README.md @@ -0,0 +1,227 @@ +# Core PHP Framework + +A modular monolith framework for Laravel with event-driven architecture and lazy module loading. + +## Installation + +```bash +composer require host-uk/core +``` + +The service provider will be auto-discovered. + +## Configuration + +Publish the config file: + +```bash +php artisan vendor:publish --tag=core-config +``` + +Configure your module paths in `config/core.php`: + +```php +return [ + 'module_paths' => [ + app_path('Core'), + app_path('Mod'), + ], +]; +``` + +## Creating Modules + +Use the artisan commands to scaffold modules: + +```bash +# Create a full module +php artisan make:mod Commerce + +# Create a website module (domain-scoped) +php artisan make:website Marketing + +# Create a plugin +php artisan make:plug Stripe +``` + +## Module Structure + +Modules are organised with a `Boot.php` entry point: + +``` +app/Mod/Commerce/ +├── Boot.php +├── Routes/ +│ ├── web.php +│ ├── admin.php +│ └── api.php +├── Views/ +└── config.php +``` + +## Lifecycle Events + +Modules declare interest in lifecycle events via a static `$listens` array: + +```php + 'onWebRoutes', + AdminPanelBooting::class => 'onAdmin', + ]; + + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->views('commerce', __DIR__.'/Views'); + $event->routes(fn () => require __DIR__.'/Routes/web.php'); + } + + public function onAdmin(AdminPanelBooting $event): void + { + $event->routes(fn () => require __DIR__.'/Routes/admin.php'); + } +} +``` + +### Available Events + +| Event | Purpose | +|-------|---------| +| `WebRoutesRegistering` | Public-facing web routes | +| `AdminPanelBooting` | Admin panel routes and navigation | +| `ApiRoutesRegistering` | REST API endpoints | +| `ClientRoutesRegistering` | Authenticated client/workspace routes | +| `ConsoleBooting` | Artisan commands | +| `McpToolsRegistering` | MCP tool handlers | +| `FrameworkBooted` | Late-stage initialisation | + +### Event Methods + +Events collect requests from modules: + +```php +// Register routes +$event->routes(fn () => require __DIR__.'/routes.php'); + +// Register view namespace +$event->views('namespace', __DIR__.'/Views'); + +// Register Livewire component +$event->livewire('alias', ComponentClass::class); + +// Register navigation item +$event->navigation(['label' => 'Products', 'icon' => 'box']); + +// Register Artisan command (ConsoleBooting) +$event->command(MyCommand::class); + +// Register middleware alias +$event->middleware('alias', MiddlewareClass::class); + +// Register translations +$event->translations('namespace', __DIR__.'/lang'); + +// Register Blade component path +$event->bladeComponentPath(__DIR__.'/components', 'prefix'); + +// Register policy +$event->policy(Model::class, Policy::class); +``` + +## Firing Events + +Create frontage service providers to fire events at appropriate times: + +```php +use Core\LifecycleEventProvider; + +class WebServiceProvider extends ServiceProvider +{ + public function boot(): void + { + LifecycleEventProvider::fireWebRoutes(); + } +} +``` + +## Lazy Loading + +Modules are only instantiated when their subscribed events fire. A web request doesn't load admin-only modules. An API request doesn't load web modules. This keeps your application fast. + +## Custom Namespace Mapping + +For non-standard directory structures: + +```php +$scanner = app(ModuleScanner::class); +$scanner->setNamespaceMap([ + 'CustomMod' => 'App\\CustomMod', +]); +``` + +## Contracts + +### AdminMenuProvider + +Implement for admin navigation: + +```php +use Core\Front\Admin\Contracts\AdminMenuProvider; + +class Boot implements AdminMenuProvider +{ + public function adminMenuItems(): array + { + return [ + [ + 'group' => 'services', + 'priority' => 20, + 'item' => fn () => [ + 'label' => 'Products', + 'icon' => 'box', + 'href' => route('admin.products.index'), + ], + ], + ]; + } +} +``` + +### ServiceDefinition + +For SaaS service registration: + +```php +use Core\Service\Contracts\ServiceDefinition; + +class Boot implements ServiceDefinition +{ + public static function definition(): array + { + return [ + 'code' => 'commerce', + 'module' => 'Commerce', + 'name' => 'Commerce', + 'tagline' => 'E-commerce platform', + ]; + } +} +``` + +## Testing + +```bash +composer test +``` + +## License + +EUPL-1.2. See [LICENSE](LICENSE) for details. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4ccb52b --- /dev/null +++ b/composer.json @@ -0,0 +1,42 @@ +{ + "name": "host-uk/core", + "description": "Modular monolith framework for Laravel - event-driven architecture with lazy module loading", + "keywords": ["laravel", "modular", "monolith", "framework", "events", "modules"], + "license": "EUPL-1.2", + "authors": [ + { + "name": "Host UK", + "email": "dev@host.uk.com" + } + ], + "require": { + "php": "^8.2", + "laravel/framework": "^11.0|^12.0" + }, + "require-dev": { + "orchestra/testbench": "^9.0|^10.0", + "phpunit/phpunit": "^11.0" + }, + "autoload": { + "psr-4": { + "Core\\": "src/Core/" + } + }, + "autoload-dev": { + "psr-4": { + "Core\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Core\\CoreServiceProvider" + ] + } + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/config/core.php b/config/core.php new file mode 100644 index 0000000..aa344e1 --- /dev/null +++ b/config/core.php @@ -0,0 +1,26 @@ + [ + | app_path('Core'), + | app_path('Mod'), + | ], + | + */ + + 'module_paths' => [ + // app_path('Core'), + // app_path('Mod'), + ], + +]; diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..28e5d8b --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,26 @@ + + + + + tests/Feature + + + tests/Unit + + + + + src + + + diff --git a/src/Core/Console/Commands/MakeModCommand.php b/src/Core/Console/Commands/MakeModCommand.php new file mode 100644 index 0000000..f27dc95 --- /dev/null +++ b/src/Core/Console/Commands/MakeModCommand.php @@ -0,0 +1,107 @@ +argument('name'); + $slug = Str::kebab($name); + $upperSlug = Str::upper(Str::snake($name)); + + $modulePath = app_path("Mod/{$name}"); + + if ($files->isDirectory($modulePath)) { + $this->error("Module [{$name}] already exists!"); + + return self::FAILURE; + } + + // Create directory structure + $files->makeDirectory($modulePath, 0755, true); + $files->makeDirectory("{$modulePath}/Routes", 0755, true); + $files->makeDirectory("{$modulePath}/Views", 0755, true); + + // Copy and process stubs + $stubPath = $this->getStubPath(); + + $replacements = [ + '{{ name }}' => $name, + '{{ slug }}' => $slug, + '{{ upper_slug }}' => $upperSlug, + ]; + + // Boot.php + $bootContent = $this->processStub( + $files->get("{$stubPath}/Boot.php.stub"), + $replacements + ); + $files->put("{$modulePath}/Boot.php", $bootContent); + + // Routes + $webRoutesContent = $this->processStub( + $files->get("{$stubPath}/Routes/web.php.stub"), + $replacements + ); + $files->put("{$modulePath}/Routes/web.php", $webRoutesContent); + + $adminRoutesContent = $this->processStub( + $files->get("{$stubPath}/Routes/admin.php.stub"), + $replacements + ); + $files->put("{$modulePath}/Routes/admin.php", $adminRoutesContent); + + $apiRoutesContent = $this->processStub( + $files->get("{$stubPath}/Routes/api.php.stub"), + $replacements + ); + $files->put("{$modulePath}/Routes/api.php", $apiRoutesContent); + + // Config + $configContent = $this->processStub( + $files->get("{$stubPath}/config.php.stub"), + $replacements + ); + $files->put("{$modulePath}/config.php", $configContent); + + $this->info("Module [{$name}] created successfully."); + $this->line(" app/Mod/{$name}/Boot.php"); + $this->line(" app/Mod/{$name}/Routes/web.php"); + $this->line(" app/Mod/{$name}/Routes/admin.php"); + $this->line(" app/Mod/{$name}/Routes/api.php"); + $this->line(" app/Mod/{$name}/config.php"); + + return self::SUCCESS; + } + + protected function getStubPath(): string + { + $customPath = base_path('stubs/core/Mod/Example'); + + if (is_dir($customPath)) { + return $customPath; + } + + return __DIR__.'/../../../stubs/Mod/Example'; + } + + protected function processStub(string $content, array $replacements): string + { + return str_replace( + array_keys($replacements), + array_values($replacements), + $content + ); + } +} diff --git a/src/Core/Console/Commands/MakePlugCommand.php b/src/Core/Console/Commands/MakePlugCommand.php new file mode 100644 index 0000000..7abb24a --- /dev/null +++ b/src/Core/Console/Commands/MakePlugCommand.php @@ -0,0 +1,73 @@ +argument('name'); + $slug = Str::kebab($name); + + $modulePath = app_path("Plug/{$name}"); + + if ($files->isDirectory($modulePath)) { + $this->error("Plugin [{$name}] already exists!"); + + return self::FAILURE; + } + + // Create directory structure + $files->makeDirectory($modulePath, 0755, true); + + // Copy and process stubs + $stubPath = $this->getStubPath(); + + $replacements = [ + '{{ name }}' => $name, + '{{ slug }}' => $slug, + ]; + + // Boot.php + $bootContent = $this->processStub( + $files->get("{$stubPath}/Boot.php.stub"), + $replacements + ); + $files->put("{$modulePath}/Boot.php", $bootContent); + + $this->info("Plugin [{$name}] created successfully."); + $this->line(" app/Plug/{$name}/Boot.php"); + + return self::SUCCESS; + } + + protected function getStubPath(): string + { + $customPath = base_path('stubs/core/Plug/Example'); + + if (is_dir($customPath)) { + return $customPath; + } + + return __DIR__.'/../../../stubs/Plug/Example'; + } + + protected function processStub(string $content, array $replacements): string + { + return str_replace( + array_keys($replacements), + array_values($replacements), + $content + ); + } +} diff --git a/src/Core/Console/Commands/MakeWebsiteCommand.php b/src/Core/Console/Commands/MakeWebsiteCommand.php new file mode 100644 index 0000000..188f1da --- /dev/null +++ b/src/Core/Console/Commands/MakeWebsiteCommand.php @@ -0,0 +1,113 @@ +argument('name'); + $slug = Str::kebab($name); + + $modulePath = app_path("Website/{$name}"); + + if ($files->isDirectory($modulePath)) { + $this->error("Website [{$name}] already exists!"); + + return self::FAILURE; + } + + // Create directory structure + $files->makeDirectory($modulePath, 0755, true); + $files->makeDirectory("{$modulePath}/Routes", 0755, true); + $files->makeDirectory("{$modulePath}/Views", 0755, true); + + // Copy and process stubs + $stubPath = $this->getStubPath(); + + $replacements = [ + '{{ name }}' => $name, + '{{ slug }}' => $slug, + ]; + + // Boot.php + $bootContent = $this->processStub( + $files->get("{$stubPath}/Boot.php.stub"), + $replacements + ); + $files->put("{$modulePath}/Boot.php", $bootContent); + + // Create basic web routes file + $webRoutes = <<name('{$slug}.home'); +PHP; + + $files->put("{$modulePath}/Routes/web.php", $webRoutes); + + // Create basic view + $indexView = << + + + + + {$name} + + +

{$name}

+ + +BLADE; + + $files->put("{$modulePath}/Views/index.blade.php", $indexView); + + $this->info("Website [{$name}] created successfully."); + $this->line(" app/Website/{$name}/Boot.php"); + $this->line(" app/Website/{$name}/Routes/web.php"); + $this->line(" app/Website/{$name}/Views/index.blade.php"); + + return self::SUCCESS; + } + + protected function getStubPath(): string + { + $customPath = base_path('stubs/core/Website/Example'); + + if (is_dir($customPath)) { + return $customPath; + } + + return __DIR__.'/../../../stubs/Website/Example'; + } + + protected function processStub(string $content, array $replacements): string + { + return str_replace( + array_keys($replacements), + array_values($replacements), + $content + ); + } +} diff --git a/src/Core/CoreServiceProvider.php b/src/Core/CoreServiceProvider.php new file mode 100644 index 0000000..7aa3b70 --- /dev/null +++ b/src/Core/CoreServiceProvider.php @@ -0,0 +1,73 @@ + 'onWebRoutes', + * AdminPanelBooting::class => 'onAdmin', + * ]; + * + * The module is only instantiated when its events fire. + */ +class CoreServiceProvider extends ServiceProvider +{ + public function register(): void + { + $this->mergeConfigFrom(__DIR__.'/../../config/core.php', 'core'); + + $this->app->singleton(ModuleScanner::class); + $this->app->singleton(ModuleRegistry::class, function ($app) { + return new ModuleRegistry($app->make(ModuleScanner::class)); + }); + + $paths = config('core.module_paths', []); + + if (! empty($paths)) { + $registry = $this->app->make(ModuleRegistry::class); + $registry->register($paths); + } + } + + public function boot(): void + { + if ($this->app->runningInConsole()) { + $this->commands([ + MakeModCommand::class, + MakeWebsiteCommand::class, + MakePlugCommand::class, + ]); + + $this->publishes([ + __DIR__.'/../../config/core.php' => config_path('core.php'), + ], 'core-config'); + + $this->publishes([ + __DIR__.'/../../stubs' => base_path('stubs/core'), + ], 'core-stubs'); + } + + $this->app->booted(function () { + event(new FrameworkBooted); + }); + } +} diff --git a/src/Core/Events/AdminPanelBooting.php b/src/Core/Events/AdminPanelBooting.php new file mode 100644 index 0000000..8c51e9a --- /dev/null +++ b/src/Core/Events/AdminPanelBooting.php @@ -0,0 +1,21 @@ +navigationRequests[] = $item; + } + + /** + * Request routes be registered. + */ + public function routes(callable $callback): void + { + $this->routeRequests[] = $callback; + } + + /** + * Request a view namespace be registered. + */ + public function views(string $namespace, string $path): void + { + $this->viewRequests[] = [$namespace, $path]; + } + + /** + * Request a middleware alias be registered. + */ + public function middleware(string $alias, string $class): void + { + $this->middlewareRequests[] = [$alias, $class]; + } + + /** + * Request a Livewire component be registered. + */ + public function livewire(string $alias, string $class): void + { + $this->livewireRequests[] = [$alias, $class]; + } + + /** + * Request an Artisan command be registered. + */ + public function command(string $class): void + { + $this->commandRequests[] = $class; + } + + /** + * Request translations be loaded for a namespace. + */ + public function translations(string $namespace, string $path): void + { + $this->translationRequests[] = [$namespace, $path]; + } + + /** + * Request an anonymous Blade component path be registered. + */ + public function bladeComponentPath(string $path, ?string $namespace = null): void + { + $this->bladeComponentRequests[] = [$path, $namespace]; + } + + /** + * Request a policy be registered for a model. + */ + public function policy(string $model, string $policy): void + { + $this->policyRequests[] = [$model, $policy]; + } + + /** + * Get all navigation requests for processing. + */ + public function navigationRequests(): array + { + return $this->navigationRequests; + } + + /** + * Get all route requests for processing. + */ + public function routeRequests(): array + { + return $this->routeRequests; + } + + /** + * Get all view namespace requests for processing. + */ + public function viewRequests(): array + { + return $this->viewRequests; + } + + /** + * Get all middleware alias requests for processing. + */ + public function middlewareRequests(): array + { + return $this->middlewareRequests; + } + + /** + * Get all Livewire component requests for processing. + */ + public function livewireRequests(): array + { + return $this->livewireRequests; + } + + /** + * Get all command requests for processing. + */ + public function commandRequests(): array + { + return $this->commandRequests; + } + + /** + * Get all translation requests for processing. + */ + public function translationRequests(): array + { + return $this->translationRequests; + } + + /** + * Get all Blade component path requests for processing. + */ + public function bladeComponentRequests(): array + { + return $this->bladeComponentRequests; + } + + /** + * Get all policy requests for processing. + */ + public function policyRequests(): array + { + return $this->policyRequests; + } +} diff --git a/src/Core/Events/McpToolsRegistering.php b/src/Core/Events/McpToolsRegistering.php new file mode 100644 index 0000000..61ea366 --- /dev/null +++ b/src/Core/Events/McpToolsRegistering.php @@ -0,0 +1,36 @@ +handlers[] = $handlerClass; + } + + /** + * Get all registered handler classes. + */ + public function handlers(): array + { + return $this->handlers; + } +} diff --git a/src/Core/Events/WebRoutesRegistering.php b/src/Core/Events/WebRoutesRegistering.php new file mode 100644 index 0000000..711d9bb --- /dev/null +++ b/src/Core/Events/WebRoutesRegistering.php @@ -0,0 +1,19 @@ + 'services', + * 'priority' => 20, + * 'item' => fn() => [ + * 'label' => 'Products', + * 'icon' => 'box', + * 'href' => route('admin.products.index'), + * 'active' => request()->routeIs('admin.products.*'), + * 'children' => [...], + * ], + * ], + * ]; + * ``` + * + * @return array + */ + public function adminMenuItems(): array; +} diff --git a/src/Core/LifecycleEventProvider.php b/src/Core/LifecycleEventProvider.php new file mode 100644 index 0000000..41f9a4a --- /dev/null +++ b/src/Core/LifecycleEventProvider.php @@ -0,0 +1,171 @@ +viewRequests() as [$namespace, $path]) { + if (is_dir($path)) { + view()->addNamespace($namespace, $path); + } + } + + // Process Livewire component requests if Livewire is available + if (class_exists(\Livewire\Livewire::class)) { + foreach ($event->livewireRequests() as [$alias, $class]) { + \Livewire\Livewire::component($alias, $class); + } + } + + // Process route requests + foreach ($event->routeRequests() as $callback) { + Route::middleware('web')->group($callback); + } + + // Refresh route lookups after adding routes + app('router')->getRoutes()->refreshNameLookups(); + app('router')->getRoutes()->refreshActionLookups(); + } + + /** + * Fire AdminPanelBooting and process requests. + * + * Called by your admin frontage when admin routes are being set up. + */ + public static function fireAdminBooting(): void + { + $event = new AdminPanelBooting; + event($event); + + // Process view namespace requests + foreach ($event->viewRequests() as [$namespace, $path]) { + if (is_dir($path)) { + view()->addNamespace($namespace, $path); + } + } + + // Process Livewire component requests if Livewire is available + if (class_exists(\Livewire\Livewire::class)) { + foreach ($event->livewireRequests() as [$alias, $class]) { + \Livewire\Livewire::component($alias, $class); + } + } + + // Process route requests with admin middleware + foreach ($event->routeRequests() as $callback) { + Route::middleware('admin')->group($callback); + } + } + + /** + * Fire ClientRoutesRegistering and process requests. + * + * Called by your client frontage when client routes are being set up. + */ + public static function fireClientRoutes(): void + { + $event = new ClientRoutesRegistering; + event($event); + + // Process view namespace requests + foreach ($event->viewRequests() as [$namespace, $path]) { + if (is_dir($path)) { + view()->addNamespace($namespace, $path); + } + } + + // Process Livewire component requests if Livewire is available + if (class_exists(\Livewire\Livewire::class)) { + foreach ($event->livewireRequests() as [$alias, $class]) { + \Livewire\Livewire::component($alias, $class); + } + } + + // Process route requests with client middleware + foreach ($event->routeRequests() as $callback) { + Route::middleware('client')->group($callback); + } + + // Refresh route lookups after adding routes + app('router')->getRoutes()->refreshNameLookups(); + app('router')->getRoutes()->refreshActionLookups(); + } + + /** + * Fire ApiRoutesRegistering and process requests. + * + * Called by your API frontage when API routes are being set up. + */ + public static function fireApiRoutes(): void + { + $event = new ApiRoutesRegistering; + event($event); + + // Process route requests with api middleware + foreach ($event->routeRequests() as $callback) { + Route::middleware('api')->prefix('api')->group($callback); + } + } + + /** + * Fire McpToolsRegistering and return collected handlers. + * + * Called by MCP server command when loading tools. + * + * @return array Handler class names + */ + public static function fireMcpTools(): array + { + $event = new McpToolsRegistering; + event($event); + + return $event->handlers(); + } + + /** + * Fire ConsoleBooting and process requests. + * + * @param \Illuminate\Support\ServiceProvider $provider The service provider to register commands on + */ + public static function fireConsoleBooting(\Illuminate\Support\ServiceProvider $provider): void + { + $event = new ConsoleBooting; + event($event); + + // Process command requests + if (! empty($event->commandRequests())) { + $provider->commands($event->commandRequests()); + } + } +} diff --git a/src/Core/Module/LazyModuleListener.php b/src/Core/Module/LazyModuleListener.php new file mode 100644 index 0000000..d8d09e7 --- /dev/null +++ b/src/Core/Module/LazyModuleListener.php @@ -0,0 +1,92 @@ +resolveModule(); + $module->{$this->method}($event); + } + + /** + * Alias for __invoke for explicit calls. + */ + public function handle(object $event): void + { + $this->__invoke($event); + } + + /** + * Resolve the module instance. + * + * ServiceProviders are resolved via resolveProvider() to get proper $app injection. + * Plain classes are resolved via make(). + */ + private function resolveModule(): object + { + if ($this->instance !== null) { + return $this->instance; + } + + $app = app(); + + // Check if this is a ServiceProvider + if (is_subclass_of($this->moduleClass, ServiceProvider::class)) { + // Use resolveProvider for ServiceProviders - handles $app injection + $this->instance = $app->resolveProvider($this->moduleClass); + } else { + // Plain class - just make it + $this->instance = $app->make($this->moduleClass); + } + + return $this->instance; + } + + /** + * Get the module class this listener wraps. + */ + public function getModuleClass(): string + { + return $this->moduleClass; + } + + /** + * Get the method this listener will call. + */ + public function getMethod(): string + { + return $this->method; + } +} diff --git a/src/Core/Module/ModuleRegistry.php b/src/Core/Module/ModuleRegistry.php new file mode 100644 index 0000000..b3cfa6d --- /dev/null +++ b/src/Core/Module/ModuleRegistry.php @@ -0,0 +1,106 @@ +register([app_path('Core'), app_path('Mod')]); + */ +class ModuleRegistry +{ + private array $mappings = []; + + private bool $registered = false; + + public function __construct( + private ModuleScanner $scanner + ) {} + + /** + * Scan paths and register lazy listeners for all declared events. + * + * @param array $paths Directories containing modules + */ + public function register(array $paths): void + { + if ($this->registered) { + return; + } + + $this->mappings = $this->scanner->scan($paths); + + foreach ($this->mappings as $event => $listeners) { + foreach ($listeners as $moduleClass => $method) { + Event::listen($event, new LazyModuleListener($moduleClass, $method)); + } + } + + $this->registered = true; + } + + /** + * Get all scanned mappings. + * + * @return array> Event => [Module => method] + */ + public function getMappings(): array + { + return $this->mappings; + } + + /** + * Get modules that listen to a specific event. + * + * @return array Module => method + */ + public function getListenersFor(string $event): array + { + return $this->mappings[$event] ?? []; + } + + /** + * Check if registration has been performed. + */ + public function isRegistered(): bool + { + return $this->registered; + } + + /** + * Get all events that have listeners. + * + * @return array + */ + public function getEvents(): array + { + return array_keys($this->mappings); + } + + /** + * Get all modules that have declared listeners. + * + * @return array + */ + public function getModules(): array + { + $modules = []; + + foreach ($this->mappings as $listeners) { + foreach (array_keys($listeners) as $module) { + $modules[$module] = true; + } + } + + return array_keys($modules); + } +} diff --git a/src/Core/Module/ModuleScanner.php b/src/Core/Module/ModuleScanner.php new file mode 100644 index 0000000..fcc727e --- /dev/null +++ b/src/Core/Module/ModuleScanner.php @@ -0,0 +1,157 @@ +scan([app_path('Core'), app_path('Mod')]); + * // Returns: [EventClass => [ModuleClass => 'methodName']] + */ +class ModuleScanner +{ + /** + * Namespace mappings for path resolution. + * + * Maps directory names to their PSR-4 namespaces. + */ + protected array $namespaceMap = []; + + /** + * Set custom namespace mappings. + * + * @param array $map Directory name => namespace prefix + */ + public function setNamespaceMap(array $map): self + { + $this->namespaceMap = $map; + + return $this; + } + + /** + * Scan directories for Boot.php files with $listens declarations. + * + * @param array $paths Directories to scan + * @return array> Event => [Module => method] mappings + */ + public function scan(array $paths): array + { + $mappings = []; + + foreach ($paths as $path) { + if (! is_dir($path)) { + continue; + } + + foreach (glob("{$path}/*/Boot.php") as $file) { + $class = $this->classFromFile($file, $path); + + if (! $class || ! class_exists($class)) { + continue; + } + + $listens = $this->extractListens($class); + + foreach ($listens as $event => $method) { + $mappings[$event][$class] = $method; + } + } + } + + return $mappings; + } + + /** + * Extract the $listens array from a class without instantiation. + * + * @return array Event => method mappings + */ + public function extractListens(string $class): array + { + try { + $reflection = new ReflectionClass($class); + + if (! $reflection->hasProperty('listens')) { + return []; + } + + $prop = $reflection->getProperty('listens'); + + if (! $prop->isStatic() || ! $prop->isPublic()) { + return []; + } + + $listens = $prop->getValue(); + + if (! is_array($listens)) { + return []; + } + + return $listens; + } catch (\ReflectionException) { + return []; + } + } + + /** + * Derive class name from file path. + * + * Converts: app/Mod/Commerce/Boot.php => Mod\Commerce\Boot + * Converts: app/Core/Cdn/Boot.php => Core\Cdn\Boot + */ + private function classFromFile(string $file, string $basePath): ?string + { + // Normalise paths + $file = str_replace('\\', '/', realpath($file) ?: $file); + $basePath = str_replace('\\', '/', realpath($basePath) ?: $basePath); + + // Get relative path from base + if (! str_starts_with($file, $basePath)) { + return null; + } + + $relative = substr($file, strlen($basePath) + 1); + + // Remove .php extension + $relative = preg_replace('/\.php$/', '', $relative); + + // Convert path separators to namespace separators + $namespace = str_replace('/', '\\', $relative); + + // Check custom namespace map first + $dirName = basename($basePath); + if (isset($this->namespaceMap[$dirName])) { + return $this->namespaceMap[$dirName].'\\'.$namespace; + } + + // Default namespace detection based on common patterns + if (str_contains($basePath, '/Core')) { + return "Core\\{$namespace}"; + } + + if (str_contains($basePath, '/Mod')) { + return "Mod\\{$namespace}"; + } + + if (str_contains($basePath, '/Website')) { + return "Website\\{$namespace}"; + } + + if (str_contains($basePath, '/Plug')) { + return "Plug\\{$namespace}"; + } + + // Fallback - use directory name as namespace + return "{$dirName}\\{$namespace}"; + } +} diff --git a/src/Core/Service/Contracts/ServiceDefinition.php b/src/Core/Service/Contracts/ServiceDefinition.php new file mode 100644 index 0000000..7d69da1 --- /dev/null +++ b/src/Core/Service/Contracts/ServiceDefinition.php @@ -0,0 +1,35 @@ + 'onWebRoutes', + AdminPanelBooting::class => 'onAdmin', + ]; + + /** + * Handle web routes registration. + */ + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->views('{{ slug }}', __DIR__.'/Views'); + + $event->routes(function () { + require __DIR__.'/Routes/web.php'; + }); + } + + /** + * Handle admin panel booting. + */ + public function onAdmin(AdminPanelBooting $event): void + { + $event->routes(function () { + require __DIR__.'/Routes/admin.php'; + }); + } +} diff --git a/stubs/Mod/Example/Routes/admin.php.stub b/stubs/Mod/Example/Routes/admin.php.stub new file mode 100644 index 0000000..403819b --- /dev/null +++ b/stubs/Mod/Example/Routes/admin.php.stub @@ -0,0 +1,19 @@ +name('admin.{{ slug }}.')->group(function () { +// Route::get('/', function () { +// return view('{{ slug }}::admin.index'); +// })->name('index'); +// }); diff --git a/stubs/Mod/Example/Routes/api.php.stub b/stubs/Mod/Example/Routes/api.php.stub new file mode 100644 index 0000000..1132e82 --- /dev/null +++ b/stubs/Mod/Example/Routes/api.php.stub @@ -0,0 +1,19 @@ +name('api.{{ slug }}.')->group(function () { +// Route::get('/', function () { +// return response()->json(['module' => '{{ name }}']); +// })->name('index'); +// }); diff --git a/stubs/Mod/Example/Routes/web.php.stub b/stubs/Mod/Example/Routes/web.php.stub new file mode 100644 index 0000000..6ed282a --- /dev/null +++ b/stubs/Mod/Example/Routes/web.php.stub @@ -0,0 +1,17 @@ +name('{{ slug }}.index'); diff --git a/stubs/Mod/Example/config.php.stub b/stubs/Mod/Example/config.php.stub new file mode 100644 index 0000000..9c2f6fd --- /dev/null +++ b/stubs/Mod/Example/config.php.stub @@ -0,0 +1,13 @@ + env('{{ upper_slug }}_ENABLED', true), + +]; diff --git a/stubs/Plug/Example/Boot.php.stub b/stubs/Plug/Example/Boot.php.stub new file mode 100644 index 0000000..777b8b4 --- /dev/null +++ b/stubs/Plug/Example/Boot.php.stub @@ -0,0 +1,33 @@ + 'onBooted', + ]; + + /** + * Handle framework booted event. + */ + public function onBooted(FrameworkBooted $event): void + { + // Register plugin functionality + } +} diff --git a/stubs/Website/Example/Boot.php.stub b/stubs/Website/Example/Boot.php.stub new file mode 100644 index 0000000..f42a23f --- /dev/null +++ b/stubs/Website/Example/Boot.php.stub @@ -0,0 +1,36 @@ + 'onWebRoutes', + ]; + + /** + * Handle web routes registration. + */ + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->views('{{ slug }}', __DIR__.'/Views'); + + $event->routes(function () { + require __DIR__.'/Routes/web.php'; + }); + } +} diff --git a/tests/Feature/LazyModuleListenerTest.php b/tests/Feature/LazyModuleListenerTest.php new file mode 100644 index 0000000..e2f9f9d --- /dev/null +++ b/tests/Feature/LazyModuleListenerTest.php @@ -0,0 +1,84 @@ +assertEquals('App\\Mod\\Test\\Boot', $listener->getModuleClass()); + } + + public function test_listener_stores_method(): void + { + $listener = new LazyModuleListener( + 'App\\Mod\\Test\\Boot', + 'onWebRoutes' + ); + + $this->assertEquals('onWebRoutes', $listener->getMethod()); + } + + public function test_listener_invokes_module_method(): void + { + // Create a test module class + $moduleClass = new class + { + public bool $called = false; + + public function onWebRoutes(WebRoutesRegistering $event): void + { + $this->called = true; + } + }; + + // Bind it to the container + $this->app->instance($moduleClass::class, $moduleClass); + + $listener = new LazyModuleListener( + $moduleClass::class, + 'onWebRoutes' + ); + + $event = new WebRoutesRegistering; + $listener($event); + + $this->assertTrue($moduleClass->called); + } + + public function test_handle_is_alias_for_invoke(): void + { + $moduleClass = new class + { + public int $callCount = 0; + + public function onWebRoutes(WebRoutesRegistering $event): void + { + $this->callCount++; + } + }; + + $this->app->instance($moduleClass::class, $moduleClass); + + $listener = new LazyModuleListener( + $moduleClass::class, + 'onWebRoutes' + ); + + $event = new WebRoutesRegistering; + $listener->handle($event); + + $this->assertEquals(1, $moduleClass->callCount); + } +} diff --git a/tests/Feature/LifecycleEventsTest.php b/tests/Feature/LifecycleEventsTest.php new file mode 100644 index 0000000..31e4c7d --- /dev/null +++ b/tests/Feature/LifecycleEventsTest.php @@ -0,0 +1,136 @@ +routes(fn () => 'test'); + + $this->assertCount(1, $event->routeRequests()); + } + + public function test_web_routes_event_collects_view_requests(): void + { + $event = new WebRoutesRegistering; + + $event->views('test', '/path/to/views'); + + $requests = $event->viewRequests(); + $this->assertCount(1, $requests); + $this->assertEquals(['test', '/path/to/views'], $requests[0]); + } + + public function test_admin_event_collects_navigation_requests(): void + { + $event = new AdminPanelBooting; + + $event->navigation(['label' => 'Test', 'icon' => 'cog']); + + $this->assertCount(1, $event->navigationRequests()); + } + + public function test_api_event_collects_route_requests(): void + { + $event = new ApiRoutesRegistering; + + $event->routes(fn () => 'api-test'); + + $this->assertCount(1, $event->routeRequests()); + } + + public function test_client_event_collects_livewire_requests(): void + { + $event = new ClientRoutesRegistering; + + $event->livewire('test-component', 'App\\Livewire\\TestComponent'); + + $requests = $event->livewireRequests(); + $this->assertCount(1, $requests); + $this->assertEquals(['test-component', 'App\\Livewire\\TestComponent'], $requests[0]); + } + + public function test_console_event_collects_command_requests(): void + { + $event = new ConsoleBooting; + + $event->command('App\\Console\\Commands\\TestCommand'); + + $this->assertCount(1, $event->commandRequests()); + } + + public function test_mcp_event_collects_handlers(): void + { + $event = new McpToolsRegistering; + + $event->handler('App\\Mcp\\TestHandler'); + + $this->assertCount(1, $event->handlers()); + $this->assertEquals(['App\\Mcp\\TestHandler'], $event->handlers()); + } + + public function test_framework_booted_event_exists(): void + { + $event = new FrameworkBooted; + + $this->assertInstanceOf(FrameworkBooted::class, $event); + } + + public function test_lifecycle_event_collects_middleware_requests(): void + { + $event = new WebRoutesRegistering; + + $event->middleware('custom', 'App\\Http\\Middleware\\Custom'); + + $requests = $event->middlewareRequests(); + $this->assertCount(1, $requests); + $this->assertEquals(['custom', 'App\\Http\\Middleware\\Custom'], $requests[0]); + } + + public function test_lifecycle_event_collects_translation_requests(): void + { + $event = new WebRoutesRegistering; + + $event->translations('test', '/path/to/lang'); + + $requests = $event->translationRequests(); + $this->assertCount(1, $requests); + $this->assertEquals(['test', '/path/to/lang'], $requests[0]); + } + + public function test_lifecycle_event_collects_policy_requests(): void + { + $event = new WebRoutesRegistering; + + $event->policy('App\\Models\\User', 'App\\Policies\\UserPolicy'); + + $requests = $event->policyRequests(); + $this->assertCount(1, $requests); + $this->assertEquals(['App\\Models\\User', 'App\\Policies\\UserPolicy'], $requests[0]); + } + + public function test_lifecycle_event_collects_blade_component_requests(): void + { + $event = new WebRoutesRegistering; + + $event->bladeComponentPath('/path/to/components', 'custom'); + + $requests = $event->bladeComponentRequests(); + $this->assertCount(1, $requests); + $this->assertEquals(['/path/to/components', 'custom'], $requests[0]); + } +} diff --git a/tests/Feature/ModuleRegistryTest.php b/tests/Feature/ModuleRegistryTest.php new file mode 100644 index 0000000..65a9fb4 --- /dev/null +++ b/tests/Feature/ModuleRegistryTest.php @@ -0,0 +1,72 @@ +assertFalse($registry->isRegistered()); + } + + public function test_registry_marks_as_registered_after_register(): void + { + $registry = new ModuleRegistry(new ModuleScanner); + + $registry->register([]); + + $this->assertTrue($registry->isRegistered()); + } + + public function test_registry_only_registers_once(): void + { + $registry = new ModuleRegistry(new ModuleScanner); + + $registry->register([]); + $registry->register([$this->getFixturePath('Mod')]); + + // Should still be empty since it only registers once + $this->assertEmpty($registry->getMappings()); + } + + public function test_get_mappings_returns_array(): void + { + $registry = new ModuleRegistry(new ModuleScanner); + + $this->assertIsArray($registry->getMappings()); + } + + public function test_get_listeners_for_returns_empty_for_unknown_event(): void + { + $registry = new ModuleRegistry(new ModuleScanner); + + $result = $registry->getListenersFor('Unknown\\Event'); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function test_get_events_returns_array(): void + { + $registry = new ModuleRegistry(new ModuleScanner); + $registry->register([]); + + $this->assertIsArray($registry->getEvents()); + } + + public function test_get_modules_returns_array(): void + { + $registry = new ModuleRegistry(new ModuleScanner); + $registry->register([]); + + $this->assertIsArray($registry->getModules()); + } +} diff --git a/tests/Feature/ModuleScannerTest.php b/tests/Feature/ModuleScannerTest.php new file mode 100644 index 0000000..12ca162 --- /dev/null +++ b/tests/Feature/ModuleScannerTest.php @@ -0,0 +1,56 @@ +scanner = new ModuleScanner; + } + + public function test_scan_returns_empty_array_for_nonexistent_path(): void + { + $result = $this->scanner->scan(['/nonexistent/path']); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function test_scan_finds_modules_with_listens_property(): void + { + $result = $this->scanner->scan([$this->getFixturePath('Mod')]); + + $this->assertIsArray($result); + } + + public function test_extract_listens_returns_empty_for_class_without_property(): void + { + // Create a temporary class without $listens + $class = new class { + public function handle(): void {} + }; + + $result = $this->scanner->extractListens($class::class); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function test_namespace_map_can_be_configured(): void + { + $scanner = (new ModuleScanner)->setNamespaceMap([ + 'CustomMod' => 'App\\CustomMod', + ]); + + $this->assertInstanceOf(ModuleScanner::class, $scanner); + } +} diff --git a/tests/Fixtures/Mod/Example/Boot.php b/tests/Fixtures/Mod/Example/Boot.php new file mode 100644 index 0000000..1bca88d --- /dev/null +++ b/tests/Fixtures/Mod/Example/Boot.php @@ -0,0 +1,19 @@ + 'onWebRoutes', + ]; + + public function onWebRoutes(WebRoutesRegistering $event): void + { + $event->views('example', __DIR__.'/Views'); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..618ca4b --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,30 @@ +set('core.module_paths', [ + $this->getFixturePath('Mod'), + ]); + } + + protected function getFixturePath(string $path = ''): string + { + return __DIR__.'/Fixtures'.($path ? "/{$path}" : ''); + } +}