Browse Source

feat: scaffold domain-specific providers, add runtime relationship support, and improve event registration logic.

master
Syarif Ubaidillah 1 week ago
parent
commit
97495f9991
  1. 4
      app/Console/Commands/DomainMakeCommand.php
  2. 130
      app/Console/Commands/DomainNewCommand.php
  3. 23
      app/Console/stubs/domain-make/domain-provider.stub
  4. 31
      app/Console/stubs/domain-make/relationship-provider.stub
  5. 35
      app/Console/stubs/domain-make/view-provider.stub
  6. 15
      app/Domains/Account/Providers/AccountServiceProvider.php
  7. 41
      app/Domains/Account/Providers/RelationshipServiceProvider.php
  8. 5
      app/Domains/Identity/Models/User.php
  9. 13
      app/Domains/Identity/Providers/IdentityServiceProvider.php
  10. 30
      app/Domains/Identity/Providers/RelationshipServiceProvider.php
  11. 31
      app/Domains/System/Providers/RelationshipServiceProvider.php
  12. 24
      app/Domains/System/Providers/SystemServiceProvider.php
  13. 28
      app/Domains/System/Providers/ViewServiceProvider.php
  14. 43
      app/Domains/System/Traits/Provider/RegistersDomainEvents.php
  15. 39
      app/Providers/EventServiceProvider.php
  16. 21
      bootstrap/providers.php
  17. 2
      config/audit.php
  18. 2
      lang/en/domains/identity/messages.php
  19. 2
      lang/id/domains/identity/messages.php
  20. 1
      routes/api.php

4
app/Console/Commands/DomainMakeCommand.php

@ -9,7 +9,7 @@ use Illuminate\Support\Str;
class DomainMakeCommand extends Command
{
protected $signature = 'domain:make
{type : Type to generate: model, action, dto, enum, event, listener, notification, policy, query, provider, export, mapper, scope, mailable}
{type : Type to generate: model, action, dto, enum, event, listener, notification, policy, query, provider, relationship-provider, export, mapper, scope, mailable}
{domain : Domain name, e.g. Identity, Account, System}
{name : Class name, supports sub-paths e.g. Backup/DeleteBackup}
{--factory : Also generate a factory (model only)}
@ -32,6 +32,8 @@ class DomainMakeCommand extends Command
'trait' => 'Traits',
'query' => 'Queries',
'provider' => 'Providers',
'relationship-provider' => 'Providers',
'view-provider' => 'Providers',
'export' => 'Exports',
// Integration layer — files live under Integration/<subDir>/
'mapper' => 'Integration/Mappers',

130
app/Console/Commands/DomainNewCommand.php

@ -0,0 +1,130 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem;
class DomainNewCommand extends Command
{
protected $signature = 'domain:new
{domain : Domain name, e.g. Order, Payment, Billing}';
protected $description = 'Scaffold a new domain with a service provider and a relationship provider';
public function __construct(protected Filesystem $files)
{
parent::__construct();
}
public function handle(): int
{
$domain = ucfirst($this->argument('domain'));
$namespace = "App\\Domains\\{$domain}\\Providers";
$providerDir = app_path("Domains/{$domain}/Providers");
if ($this->files->isDirectory($providerDir) && count($this->files->files($providerDir)) > 0) {
$this->components->error(
"Domain [{$domain}] already has providers. ".
"Use [domain:make provider] to add individual providers."
);
return self::FAILURE;
}
$this->files->ensureDirectoryExists($providerDir);
$domainProviderClass = "{$domain}ServiceProvider";
if (! $this->createDomainProvider($providerDir, $namespace, $domainProviderClass)) {
return self::FAILURE;
}
if (! $this->createRelationshipProvider($providerDir, $namespace)) {
return self::FAILURE;
}
$this->registerInBootstrap($domain, $namespace, $domainProviderClass);
$this->components->twoColumnDetail(
"Domain <fg=green;options=bold>{$domain}</> scaffolded",
'<fg=green;options=bold>DONE</>'
);
return self::SUCCESS;
}
// ─── Generators ──────────────────────────────────────────────────────────
protected function createDomainProvider(string $dir, string $namespace, string $class): bool
{
$path = "{$dir}/{$class}.php";
$content = $this->buildFromStub('domain-provider', $namespace, $class);
if ($content === '') {
return false;
}
$this->files->put($path, $content);
$this->components->info("Provider [{$path}] created successfully.");
return true;
}
protected function createRelationshipProvider(string $dir, string $namespace): bool
{
$path = "{$dir}/RelationshipServiceProvider.php";
$content = $this->buildFromStub('relationship-provider', $namespace, 'RelationshipServiceProvider');
if ($content === '') {
return false;
}
$this->files->put($path, $content);
$this->components->info("Provider [{$path}] created successfully.");
return true;
}
// ─── Bootstrap Registration ───────────────────────────────────────────────
/**
* Append the new domain service provider to bootstrap/providers.php.
*
* Inserts a commented domain block immediately before the closing ];
* so the file stays readable and grouped by domain.
*/
protected function registerInBootstrap(string $domain, string $namespace, string $class): void
{
$bootstrapPath = base_path('bootstrap/providers.php');
$content = $this->files->get($bootstrapPath);
$entry = "\n // Domain: {$domain}\n {$namespace}\\{$class}::class,\n";
// Anchor on the closing ]; — safe because providers.php has exactly one
$content = preg_replace('/(\];)(\s*)$/', "{$entry}];$2", rtrim($content))."\n";
$this->files->put($bootstrapPath, $content);
$this->components->info("Domain [{$domain}] registered in [bootstrap/providers.php].");
}
// ─── Stubs ────────────────────────────────────────────────────────────────
protected function buildFromStub(string $stubName, string $namespace, string $class): string
{
$stubPath = app_path("Console/stubs/domain-make/{$stubName}.stub");
if (! $this->files->exists($stubPath)) {
$this->components->error("Stub file not found: [{$stubPath}]");
return '';
}
return str_replace(
['{{ namespace }}', '{{ class }}'],
[$namespace, $class],
$this->files->get($stubPath),
);
}
}

23
app/Console/stubs/domain-make/domain-provider.stub

@ -0,0 +1,23 @@
<?php
namespace {{ namespace }};
use App\Domains\System\Traits\Provider\RegistersDomainEvents;
use Illuminate\Support\ServiceProvider;
class {{ class }} extends ServiceProvider
{
use RegistersDomainEvents;
protected array $listen = [];
public function register(): void
{
$this->app->register(RelationshipServiceProvider::class);
}
public function boot(): void
{
$this->registerEvents();
}
}

31
app/Console/stubs/domain-make/relationship-provider.stub

@ -0,0 +1,31 @@
<?php
namespace {{ namespace }};
use Illuminate\Support\ServiceProvider;
class {{ class }} extends ServiceProvider
{
/**
* Boot cross-domain runtime relationships.
*
* This provider is strictly dedicated to runtime, decoupled cross-domain
* relationship definitions. Use Model::resolveRelationUsing() here to wire
* explicit polymorphic or foreign-key macros between models that live in
* different domains — without introducing a hard compile-time dependency
* between those domains.
*
* Do NOT add route registrations, bindings, event listeners, or any other
* bootstrapping logic here. Keep this file focused on relationships only.
*
* Example:
*
* Order::resolveRelationUsing('customer', fn (Order $order) =>
* $order->belongsTo(Customer::class, 'customer_id')
* );
*/
public function boot(): void
{
// Register cross-domain relationships below.
}
}

35
app/Console/stubs/domain-make/view-provider.stub

@ -0,0 +1,35 @@
<?php
namespace {{ namespace }};
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class {{ class }} extends ServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
/**
* Register a view composer for presentation glue.
*
* This binds data to specific layout assets (e.g., sidebar) using
* a decoupled query pattern, keeping the domain's root provider pristine.
*/
View::composer('components.layouts.sidebar', function ($view) {
// Instantiate the internal domain model query dynamically or via DI container
// $query = $this->app->make(GetSidebarNavigationItems::class);
// $view->with('navigationItems', $query->execute());
$view->with('navigationItems', [
[
'label' => 'Dashboard',
'route' => '{domain}.dashboard',
'icon' => 'home',
],
]);
});
}
}

15
app/Domains/Account/Providers/AccountServiceProvider.php

@ -4,19 +4,26 @@ namespace App\Domains\Account\Providers;
use App\Domains\Account\Listeners\SyncImportedUserProfile;
use App\Domains\Identity\Events\Integration\UserImportWasProcessed;
use Illuminate\Support\Facades\Event;
use App\Domains\System\Traits\Provider\RegistersDomainEvents;
use Illuminate\Support\ServiceProvider;
class AccountServiceProvider extends ServiceProvider
{
public static array $listen = [
use RegistersDomainEvents;
protected array $listen = [
UserImportWasProcessed::class => [
SyncImportedUserProfile::class,
]
],
];
public function register(): void
{
$this->app->register(RelationshipServiceProvider::class);
}
public function boot(): void
{
//
$this->registerEvents();
}
}

41
app/Domains/Account/Providers/RelationshipServiceProvider.php

@ -0,0 +1,41 @@
<?php
namespace App\Domains\Account\Providers;
use App\Domains\Account\Models\Profile;
use App\Domains\Identity\Models\User;
use Illuminate\Support\ServiceProvider;
class RelationshipServiceProvider extends ServiceProvider
{
/**
* Boot cross-domain runtime relationships.
*
* This provider is strictly dedicated to runtime, decoupled cross-domain
* relationship definitions. Use Model::resolveRelationUsing() here to wire
* explicit polymorphic or foreign-key macros between models that live in
* different domains — without introducing a hard compile-time dependency
* between those domains.
*
* Do NOT add route registrations, bindings, event listeners, or any other
* bootstrapping logic here. Keep this file focused on relationships only.
*
* Example:
*
* Order::resolveRelationUsing('customer', fn (Order $order) =>
* $order->belongsTo(Customer::class, 'customer_id')
* );
*/
public function boot(): void
{
User::resolveRelationUsing('profile', fn (User $user) => $user->hasOne(
related: Profile::class,
foreignKey: 'user_id'
));
Profile::resolveRelationUsing('user', fn (Profile $profile) => $profile->belongsTo(
related: User::class,
foreignKey: 'user_id',
ownerKey: 'id',
));
}
}

5
app/Domains/Identity/Models/User.php

@ -90,9 +90,4 @@ class User extends Authenticatable implements Auditable, MustVerifyEmail
{
return $this->hasSingleFile('avatar');
}
public function profile(): HasOne
{
return $this->hasOne(Profile::class);
}
}

13
app/Domains/Identity/Providers/IdentityServiceProvider.php

@ -10,15 +10,15 @@ use App\Domains\Identity\Listeners\Authentication\SendSignInActivityNotification
use App\Domains\Identity\Listeners\Governance\SendUserActivatedNotification;
use App\Domains\Identity\Listeners\Governance\SendUserPurgedNotification;
use App\Domains\Identity\Listeners\Governance\SendUserSuspendedNotification;
use App\Domains\Identity\Models\User;
use App\Domains\Identity\Queries\GetAuthenticatedUserContext;
use App\Domains\System\Models\File;
use Illuminate\Support\Facades\Event;
use App\Domains\System\Traits\Provider\RegistersDomainEvents;
use Illuminate\Support\ServiceProvider;
class IdentityServiceProvider extends ServiceProvider
{
public static array $listen = [
use RegistersDomainEvents;
protected array $listen = [
UserLoggedIn::class => [
SendSignInActivityNotification::class,
],
@ -30,16 +30,17 @@ class IdentityServiceProvider extends ServiceProvider
],
UserWasSuspended::class => [
SendUserSuspendedNotification::class,
]
],
];
public function register(): void
{
$this->app->register(RelationshipServiceProvider::class);
$this->app->singleton(GetAuthenticatedUserContext::class, fn () => new GetAuthenticatedUserContext);
}
public function boot(): void
{
File::resolveRelationUsing('uploader', fn (File $file) => $file->belongsTo(User::class, 'uploader_id'));
$this->registerEvents();
}
}

30
app/Domains/Identity/Providers/RelationshipServiceProvider.php

@ -0,0 +1,30 @@
<?php
namespace App\Domains\Identity\Providers;
use App\Domains\Identity\Models\User;
use App\Domains\System\Models\File;
use Illuminate\Support\ServiceProvider;
class RelationshipServiceProvider extends ServiceProvider
{
/**
* Boot cross-domain runtime relationships.
*
* This provider is strictly dedicated to runtime, decoupled cross-domain
* relationship definitions. Use Model::resolveRelationUsing() here to wire
* explicit polymorphic or foreign-key macros between models that live in
* different domains — without introducing a hard compile-time dependency
* between those domains.
*
* Do NOT add route registrations, bindings, event listeners, or any other
* bootstrapping logic here. Keep this file focused on relationships only.
*/
public function boot(): void
{
File::resolveRelationUsing(
'uploader',
fn (File $file) => $file->belongsTo(User::class, 'uploader_id'),
);
}
}

31
app/Domains/System/Providers/RelationshipServiceProvider.php

@ -0,0 +1,31 @@
<?php
namespace App\Domains\System\Providers;
use Illuminate\Support\ServiceProvider;
class RelationshipServiceProvider extends ServiceProvider
{
/**
* Boot cross-domain runtime relationships.
*
* This provider is strictly dedicated to runtime, decoupled cross-domain
* relationship definitions. Use Model::resolveRelationUsing() here to wire
* explicit polymorphic or foreign-key macros between models that live in
* different domains — without introducing a hard compile-time dependency
* between those domains.
*
* Do NOT add route registrations, bindings, event listeners, or any other
* bootstrapping logic here. Keep this file focused on relationships only.
*
* Example:
*
* Order::resolveRelationUsing('customer', fn (Order $order) =>
* $order->belongsTo(Customer::class, 'customer_id')
* );
*/
public function boot(): void
{
// Register cross-domain relationships below.
}
}

24
app/Domains/System/Providers/SystemServiceProvider.php

@ -10,15 +10,17 @@ use App\Domains\System\Listeners\Excel\SendExportReportEmail;
use App\Domains\System\Listeners\Excel\SendImportReportEmail;
use App\Domains\System\Listeners\Files\RemoveUserFiles;
use App\Domains\System\Queries\GetSystemSettings;
use App\Domains\System\Traits\Provider\RegistersDomainEvents;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use OwenIt\Auditing\Models\Audit;
use View;
class SystemServiceProvider extends ServiceProvider
{
public static array $listen = [
use RegistersDomainEvents;
protected array $listen = [
ExportCompleted::class => [
SendExportReportEmail::class,
],
@ -27,11 +29,13 @@ class SystemServiceProvider extends ServiceProvider
],
UserWasPurged::class => [
RemoveUserFiles::class,
]
],
];
public function register(): void
{
$this->app->register(RelationshipServiceProvider::class);
// Tell Laravel: "Whenever someone asks for GetSystemSettings,
// give them the exact same object instance for the entire request."
$this->app->singleton(GetSystemSettings::class, function ($app) {
@ -39,19 +43,9 @@ class SystemServiceProvider extends ServiceProvider
});
}
/** @noinspection PhpInconsistentReturnPointsInspection */
public function boot(GetSystemSettings $getSystemSettings): void
public function boot(): void
{
View::composer(['components.layouts.*'], function ($view) use ($getSystemSettings) {
$view->with('logo', $getSystemSettings->get(SystemSettingKey::WEB_LOGO));
$view->with('favicon', $getSystemSettings->get(SystemSettingKey::WEB_FAVICON));
});
Audit::creating(function (Audit $model) {
if (empty($model->old_values) && empty($model->new_values)) {
return false;
}
});
$this->registerEvents();
Carbon::macro('toUserTz', fn () => $this->copy()
->tz(config('app.display_timezone', 'UTC')));

28
app/Domains/System/Providers/ViewServiceProvider.php

@ -0,0 +1,28 @@
<?php
namespace App\Domains\System\Providers;
use App\Domains\System\Enums\SystemSettingKey;
use App\Domains\System\Queries\GetSystemSettings;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class ViewServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(GetSystemSettings $getSystemSettings): void
{
/**
* Register a view composer for presentation glue.
*
* This binds data to specific layout assets (e.g., sidebar) using
* a decoupled query pattern, keeping the domain's root provider pristine.
*/
View::composer(['components.layouts.*'], function ($view) use ($getSystemSettings) {
$view->with('logo', $getSystemSettings->get(SystemSettingKey::WEB_LOGO));
$view->with('favicon', $getSystemSettings->get(SystemSettingKey::WEB_FAVICON));
});
}
}

43
app/Domains/System/Traits/Provider/RegistersDomainEvents.php

@ -0,0 +1,43 @@
<?php
namespace App\Domains\System\Traits\Provider;
use Illuminate\Support\Facades\Event;
/**
* Provides self-contained event registration for domain service providers.
*
* Usage: define a protected (or public) $listen array on the using class,
* mapping event class-strings to arrays of listener class-strings, then call
* registerEvents() — typically inside boot().
*
* Example:
*
* protected array $listen = [
* OrderPlaced::class => [
* SendOrderConfirmation::class,
* UpdateInventory::class,
* ],
* ];
*/
trait RegistersDomainEvents
{
/**
* Register all events declared in the $listen property.
*
* Iterates over $this->listen (which may be declared as public, protected,
* or static on the consuming class) and delegates each event–listener pair
* to Laravel's Event facade. No filesystem scanning is performed.
*/
public function registerEvents(): void
{
/** @var array<class-string, list<class-string>> $listen */
$listen = $this->listen ?? [];
foreach ($listen as $event => $listeners) {
foreach ($listeners as $listener) {
Event::listen($event, $listener);
}
}
}
}

39
app/Providers/EventServiceProvider.php

@ -1,39 +0,0 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
/**
* Bootstrap services.
*/
public function boot(): void
{
$this->buildDomainEvent();
}
public function buildDomainEvent(): void
{
$domainProviders = glob(app_path('Domains/*/Providers/*ServiceProvider.php'));
foreach ($domainProviders as $file) {
// Convert file path directly to its full PHP Namespace
// Example: /var/www/app/Domains/Identity/Providers/IdentityServiceProvider.php
// Becomes: App\Domains\Identity\Providers\IdentityServiceProvider
$relativePath = str_replace(app_path(), 'App', $file);
$className = str_replace(['/', '.php'], ['\\', ''], $relativePath);
// If the class exists and contains our static $listen array, register it
if (class_exists($className) && property_exists($className, 'listen')) {
foreach ($className::$listen as $event => $listeners) {
foreach ((array) $listeners as $listener) {
Event::listen($event, $listener);
}
}
}
}
}
}

21
bootstrap/providers.php

@ -3,8 +3,27 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\UiServiceProvider::class,
App\Providers\EventServiceProvider::class,
OwenIt\Auditing\AuditingServiceProvider::class,
// Domain: Account
App\Domains\Account\Providers\AccountServiceProvider::class,
// Domain: Identity
App\Domains\Identity\Providers\IdentityServiceProvider::class,
// Domain: System
App\Domains\System\Providers\SystemServiceProvider::class,
// Domain: Finance
App\Domains\Finance\Providers\FinanceServiceProvider::class,
// Domain: Channel
App\Domains\Channel\Providers\ChannelServiceProvider::class,
// Domain: Admission
App\Domains\Admission\Providers\AdmissionServiceProvider::class,
// Domain: Academic
App\Domains\Academic\Providers\AcademicServiceProvider::class,
];

2
config/audit.php

@ -107,7 +107,7 @@ return [
|
*/
'empty_values' => true,
'empty_values' => false,
'allowed_empty_values' => [
'retrieved',
],

2
lang/en/domains/identity/messages.php

@ -4,7 +4,7 @@ return [
'success' => [
'password_reset_link_sent' => 'Password reset link has been sent.',
'user_role_updated' => 'User role has been updated.',
'user_status_updated' => 'User status has been updated.'
'user_status_updated' => 'User status has been updated.',
],
'exceptions' => [
'failed_to_send_password_reset_link' => 'Failed to send password reset link.',

2
lang/id/domains/identity/messages.php

@ -4,7 +4,7 @@ return [
'success' => [
'password_reset_link_sent' => 'Tautan atur ulang kata sandi telah dikirim.',
'user_role_updated' => 'Peran pengguna telah diperbarui.',
'user_status_updated' => 'Status pengguna telah diperbarui.'
'user_status_updated' => 'Status pengguna telah diperbarui.',
],
'exceptions' => [
'failed_to_send_password_reset_link' => 'Gagal mengirim tautan atur ulang kata sandi.',

1
routes/api.php

@ -1,6 +1,5 @@
<?php
use App\Http\Controllers\Api\V1\Lookup\PermissionLookupController;
use App\Http\Controllers\Api\V1\Lookup\RoleLookupController;
use Illuminate\Support\Facades\Route;

Loading…
Cancel
Save