Files

424 lines
18 KiB
Markdown
Raw Permalink Normal View History

# Module Architecture
This project uses standalone Composer packages for shared functionality, pulled into each e-shop app as regular dependencies. `boboko/core` (this repo) is the foundational module — every e-shop requires it. Other modules (e.g. `boboko/appointments`) require `boboko/core` in turn and layer additional, optional functionality on top.
Unlike an in-repo `modules/` directory, each module here lives in **its own repository** from day one. An app pulls it in via Composer, either from a `path` repository (local development, symlinked) or a `vcs`/Packagist reference (plain dev and production).
---
## Directory Structure
A module repository (e.g. `boboko-core`) is a self-contained Composer package:
```
boboko-core/ (this repo)
composer.json
config/
core.php
database/migrations/
resources/
views/
logos/
js/
docs/
src/
Auth/
Events/
Extensions/
Filament/Pages/
Mail/
Models/
Services/
Customer/
Extensions/
Listeners/
Models/
RelationManagers/
Command/
Providers/
CoreServiceProvider.php ← entry point, registers itself + config/migrations/assets
AuthServiceProvider.php ← staff/admin auth wiring not covered elsewhere
CustomerServiceProvider.php ← ModelManifest + Customer↔User event wiring
Stoic/
```
The e-shop app (`boboko-test`) consumes it as `vendor/boboko/core`:
```
boboko-test/
app/
Models/
Customer.php ← app-level model, extends Modules\Core\Customer\Models\Customer
User.php ← app-level model, dispatches Modules\Core\Auth\Events\UserCreated
Staff.php ← app-level model, extends Modules\Core\Auth\Models\Staff
Lunar/
Extensions/ ← app's own Filament resource extensions (source of truth, wired in PanelServiceProvider)
Pages/ ← app's own Create/Edit page overrides (event dispatch timing, prefill, etc.)
Providers/
AppServiceProvider.php
PanelServiceProvider.php ← all LunarPanel wiring for this app
EventServiceProvider.php ← all Event::listen registrations for this app
config/
core.php ← published from boboko-core, per-app overrides (e.g. auto_create_customer_for_user)
modules/
Appointments/ ← a second module, symlinked/required the same way as core
```
Within a module, organise by concern rather than by type (`Auth/`, `Customer/`, not `Models/`, `Services/` at the top level).
---
## Pulling In a Module
**In the app's root `composer.json`:**
```json
"require": {
"boboko/core": "^1.0"
},
"repositories": [
{
"type": "vcs",
"url": "https://code.radical-elements.com/boboko/core.git"
}
]
```
This is the **default** — a fresh `composer install` never depends on a local checkout existing.
**When developing the module itself alongside the app**, swap the `repositories` entry for a `path` repo pointing at a sibling checkout:
```json
"repositories": [
{
"type": "path",
"url": "../boboko-core",
"options": { "symlink": true }
}
]
```
This is a **manual, deliberate swap** of `composer.json` — not automated, since Composer has no clean built-in mechanism to merge an additional repository on top of the main list per-environment (`composer.local.json` is not auto-merged in Composer 2.x; `COMPOSER_REPOSITORIES` does not merge either — both were tested and ruled out). Swap it back to `vcs` before committing, so CI/prod builds never require `../boboko-core` to exist.
### Docker Compose: the local-core mount
`docker-compose.dev.yml` does **not** mount `../boboko-core` by default — normal app development never needs the sibling checkout present. A separate override file adds just that mount:
```
docker-compose.core-dev.yml ← adds ../boboko-core:/var/www/boboko-core to app/queue/scheduler
```
```bash
# normal app dev
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# developing core locally (also requires the composer.json path-repo swap above)
docker compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.core-dev.yml up
# or: bin/dc-core.sh up
```
Docker Compose merges `volumes:` lists additively across `-f` files, so the override only needs to declare the one extra mount per service.
---
## Creating a New Module
**1. Create the repository and `composer.json`:**
```json
{
"name": "boboko/your-module",
"description": "What it does",
"type": "library",
"version": "1.0.0",
"require": {
"boboko/core": "^1.0"
},
"autoload": {
"psr-4": {
"Modules\\YourModule\\": "src/"
}
},
"extra": {
"laravel": {
"providers": [
"Modules\\YourModule\\Providers\\YourModuleServiceProvider"
]
}
}
}
```
**2. Create the service provider:**
```php
namespace Modules\YourModule\Providers;
use Illuminate\Support\ServiceProvider;
class YourModuleServiceProvider extends ServiceProvider
{
public function register(): void {}
public function boot(): void
{
$this->loadViewsFrom(__DIR__ . '/../../resources/views', 'your-module');
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
}
}
```
**Register every provider the module needs in `extra.laravel.providers`.** A module can define more than one provider (e.g. `CoreServiceProvider`, `AuthServiceProvider`, `CustomerServiceProvider`) — Composer's package auto-discovery only boots providers actually listed here. A provider class that exists but isn't listed silently never runs; this has bitten this project before (see "Provider Registration Pitfalls" below).
**3. Require it in the app's `composer.json`**, same as any other module (see "Pulling In a Module" above).
Then run `composer update boboko/your-module`.
---
## Provider Registration Pitfalls
Composer's Laravel package auto-discovery only boots providers explicitly listed in `extra.laravel.providers`. It is easy to write a second/third provider in a module (e.g. splitting concerns into `AuthServiceProvider`, `CustomerServiceProvider`) and forget to add it to that list — the class compiles fine, autoloads fine, and simply never boots. Symptoms are subtle: `ModelManifest::replace()` never runs, event listeners never attach, and nothing errors — code just silently does nothing.
**Before assuming a provider's `boot()` logic is live, check `vendor/composer/installed.json`'s `extra.laravel.providers` for the package**, not just `composer.json` (a local `path`-repo package's cached `extra` metadata does not refresh on file edits alone — `composer dump-autoload` is not enough; a full `composer update <package>` is required to re-read a changed `composer.json`, and lock-file version conflicts can block that update).
### Duplicate registration between module and app
When a module ships a provider that overlaps with app-level Filament wiring (e.g. both independently call `LunarPanel::extensions([...])` for the same resource, or both register the same model-created listener), only one should survive. Prefer whichever is **proven to run** in production (usually the app-level `PanelServiceProvider`/`CorePlugin` wiring, since that's what's actually referenced from the panel registration) and strip the duplicate from the module provider — don't leave both, since duplicate `created`/`updated` listeners fire twice (e.g. sending two invite emails).
---
## Extending the Staff Model via Traits
Modules never define their own `Staff` model. Instead, each module ships a trait with its behaviour, and `app/Models/Staff.php` opts in to whatever the app needs.
**1. Define a trait in your module:**
```php
namespace Modules\YourModule\Concerns;
trait HasYourFeature
{
public function yourRelationship(): HasMany
{
return $this->hasMany(YourModel::class);
}
}
```
**2. Use it in `app/Models/Staff.php`:**
```php
namespace App\Models;
use Modules\Core\Auth\Models\Staff as CoreStaff;
use Zap\Models\Concerns\HasSchedules;
use Modules\YourModule\Concerns\HasYourFeature;
class Staff extends CoreStaff
{
use HasSchedules;
use HasYourFeature;
}
```
The app decides which traits to compose — modules have no knowledge of each other.
---
## Model Inheritance Chain
```
Lunar\Admin\Models\Staff ← Lunar base (auth, roles, Filament)
└── Modules\Core\Auth\Models\Staff ← OTP fields
└── App\Models\Staff ← app model, composes all traits
Lunar\Models\Customer ← Lunar base (buyer, addresses, orders)
└── Modules\Core\Customer\Models\Customer ← full_name accessor, title suppressed
└── App\Models\Customer ← app model, emails()/phones(), Elorus fields
```
Modules never extend each other's `Staff`/`Customer`. If a module needs staff or customer behaviour, it ships a trait, or (for core-generic behaviour) it lives directly in the module's own model, which the app then extends.
**`Customer` is not Authenticatable.** Auth is handled entirely by `App\Models\User` (see "Customer/User Pairing" below) — this changed during this project; earlier revisions made `Customer` implement `Authenticatable` directly, which has since been reverted.
---
## Customer/User Pairing
Two independent Laravel models exist per person: `App\Models\User` (the only `Authenticatable` entity, `web` guard) and `App\Models\Customer` (Lunar's buyer record — addresses, orders, no login). They are linked via Lunar's own `customer_user` pivot table (`$customer->users()` / `$user->customers()` / `$user->latestCustomer()`), not a direct foreign key — this is Lunar's native relationship shape, not something this project invented.
**Which direction creates which depends on the app:**
- **Generic e-shop flow (core's default, `config('core.auto_create_customer_for_user')`, default `true`):** a storefront visitor supplies only an email (Shopify-style passwordless). `Modules\Core\Auth\Services\UserOtpService::generateAndSend()` finds-or-creates the `User`. A genuinely new `User` fires `Modules\Core\Auth\Events\UserCreated`, handled by `Modules\Core\Customer\Listeners\CreateCustomerForUser`, which creates a bare `Customer` (no name yet — nothing was collected at signup) and attaches it via the pivot.
- **This app's flow (`boboko-test`, flag set to `false` in its published `config/core.php`):** staff create a `Customer` directly (often pre-filled from an appointment booking) with real business data (name, email, VAT, etc.). `App\Events\CustomerCreated` (dispatched manually — see "Event Timing" below) is handled by `App\Listeners\CreateUserForCustomerListener`, which finds-or-creates the paired `User` by the customer's primary email.
**A shop opts out of core's default cascade by publishing and editing `config/core.php`:**
```bash
php artisan vendor:publish --tag=core-config
```
```php
// config/core.php
'auto_create_customer_for_user' => false,
```
Both listeners guard against the other direction re-triggering: they call `User::withoutEvents(...)` around `firstOrCreate`/save, so pairing a `Customer` never spuriously fires `UserCreated` (and vice versa) even if both directions are somehow active at once.
---
## Event Timing: Filament Repeaters and Model Events
**Do not dispatch domain events from a model's `$dispatchesEvents` (`created`/`updated`) when the event's listeners depend on related data saved via a Filament relationship `Repeater`** (e.g. `Repeater::make('emails')->relationship('emails')`). Filament's `CreateRecord`/`EditRecord` pages save the base record first (firing Eloquent's `created`/`updated` immediately), then save repeater-managed relations in a separate step afterward. A listener that reads `$customer->primaryEmail()` inside a `CustomerCreated`/`CustomerUpdated` handler triggered this way will silently see no email — no error, just a no-op.
**Fix:** dispatch the event manually from the Filament page's `afterCreate()`/`afterSave()` hook instead, once relations are guaranteed persisted:
```php
// app/Lunar/Pages/CreateCustomer.php
class CreateCustomer extends BaseCreateRecord
{
protected function afterCreate(): void
{
/** @var \App\Models\Customer $customer */
$customer = $this->record;
event(new CustomerCreated($customer));
}
}
```
```php
// app/Lunar/Pages/EditCustomer.php
class EditCustomer extends BaseEditCustomer // extends Lunar's own EditCustomer, not BaseEditRecord directly
{
public function afterSave(): void
{
parent::afterSave(); // preserves Lunar's own afterSave() behaviour (e.g. sync_with_search)
event(new CustomerUpdated($this->record));
}
}
```
Remove the corresponding entry from the model's `$dispatchesEvents` array so the event only fires once, from the page.
Both pages must be wired into the resource's `extendPages()`:
```php
public function extendPages(array $pages): array
{
$pages['create'] = CreateCustomer::route('/create');
$pages['edit'] = EditCustomer::route('/{record}/edit');
return $pages;
}
```
---
## Splitting Service Providers
When a service provider grows beyond a handful of responsibilities, split it into focused sub-providers and register **each one individually** in `extra.laravel.providers` (see "Provider Registration Pitfalls" above — registering only the entry-point provider silently drops the others).
**Each sub-provider owns one concern:**
```php
// AuthServiceProvider — staff auth wiring not already covered by the app's own CorePlugin/PanelServiceProvider
class AuthServiceProvider extends ServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->app->booted(fn () => $this->commands([CreateAdminCommand::class]));
}
}
}
// CustomerServiceProvider — customer model replacement + Customer/User event wiring
class CustomerServiceProvider extends ServiceProvider
{
public function boot(): void
{
ModelManifest::replace(LunarCustomer::class, Customer::class);
Event::listen(UserCreated::class, CreateCustomerForUser::class);
}
}
```
**Rule of thumb:** if a provider has more than one `use` group (e.g. Lunar imports alongside mail imports alongside event imports), it is doing too much. If two providers (module and app) would both register the same `LunarPanel::extensions()` call or the same model-event listener, one of them is redundant — see "Provider Registration Pitfalls."
---
## Filament Panel Integration
All `LunarPanel::*` calls that must run before `LunarPanel::register()` (which snapshots `getRelations()` and locks in Livewire component registrations) live in the app's `PanelServiceProvider::register()`:
```php
// PanelServiceProvider::register()
LunarPanel::extensions([...]); // must come before register()
LunarPanel::panel(fn ($panel) => ...);
LunarPanel::disableTwoFactorAuth();
LunarPanel::register(); // snapshots everything above
```
Module-level extensions belong in the module's own sub-provider **only if the app isn't already the one wiring that resource's extension** — check `PanelServiceProvider` first. The rule: if removing the module would break the configuration, it belongs in the module; if the app already owns that wiring (common for `CustomerResource`/`StaffResource` in this project), the module's own attempt to also wire it is dead weight or a duplicate-registration bug.
---
## Overriding Lunar Relation Managers
Lunar resources ship with their own relation managers (e.g. `CustomerResource` has `AddressRelationManager`, `UserRelationManager`). To replace or remove one:
**1. Create your relation manager extending the one you want to replace** (only if you need to keep it, with changes):
```php
// app/Lunar/RelationManagers/AddressRelationManager.php
class AddressRelationManager extends BaseAddressRelationManager
{
public function getDefaultTable(Table $table): Table { ... }
}
```
**2. In the resource extension's `getRelations()`, swap or filter it out:**
```php
class CustomerResourceExtension extends BaseExtension
{
public function getRelations(array $relations): array
{
$relations = array_map(
fn ($relation) => $relation == BaseAddressRelationManager::class
? AddressRelationManager::class
: $relation,
array_filter(
$relations,
fn ($relation) => $relation != BaseUserRelationManager::class, // drop entirely
)
);
return $relations;
}
}
```
**Always compare against the Lunar base class** (`Lunar\Admin\...\AddressRelationManager`), not an intermediate class from a module. If a module has already swapped it and your extension compares against the module's class, the swap silently fails when boot order changes.
**3. Register the extension in `PanelServiceProvider::register()` before `LunarPanel::register()`** — extensions registered later (e.g. in `boot()`) are too late; Livewire component names are already locked in.
---
## Layering Module and App Configuration
`LunarPanel::extensions()` **merges** — calling it twice for the same resource registers both extensions. They run in registration order, each receiving the result of the previous one. **Only do this deliberately** — if both a module's provider and the app's `PanelServiceProvider` register an extension for the same resource without intending to layer, you get duplicate behaviour (see "Provider Registration Pitfalls").
```php
// CustomerServiceProvider::boot() — Core's fields
LunarPanel::extensions([CustomerResource::class => CoreCustomerResourceExtension::class]);
// PanelServiceProvider::register() — App's fields, applied on top
LunarPanel::extensions([CustomerResource::class => AppCustomerResourceExtension::class]);
```
Both `extendForm()` methods run: Core's first, App's second.