Files

18 KiB

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:

"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:

"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
# 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:

{
    "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:

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:

namespace Modules\YourModule\Concerns;

trait HasYourFeature
{
    public function yourRelationship(): HasMany
    {
        return $this->hasMany(YourModel::class);
    }
}

2. Use it in app/Models/Staff.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:

php artisan vendor:publish --tag=core-config
// 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:

// 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));
    }
}
// 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():

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:

// 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():

// 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):

// 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:

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").

// 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.