From 614f0200bcd0885b01f24b26343aba9ba5c293cf Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 16 Oct 2026 18:26:20 +0200 Subject: [PATCH 1/3] feat: Role facade to declare roles and capabilities the fluent way --- CHANGELOG.md | 6 ++ .../Laravel/RoleServiceProvider.php | 15 +++- src/Support/Facades/Role.php | 42 +++++++++ .../Feature/Extraction/RolesContractTest.php | 87 +++++++++++++++++++ 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/Support/Facades/Role.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e06b3d2c..1cf97f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.36.1...develop) +### Added +- A `Role` facade declares roles and capabilities the fluent way, besides `#[Role]` and `#[ModifyRole]`: `Role::make('event_manager', 'Event manager')->inherits('author')->grantsPostType(Event::class, Access::Editor)->grants('export_attendees')->without('publish_posts')`, `Role::modify('editor')` and `Role::capabilitySet(EventCap::class)`. The names are those of the attributes and of their arguments, and the same code checks both, so a mistake is refused with the same reason, naming the call (`Role::make('x') at file:line`). There is nothing to register: the declarations are checked when WordPress builds its roles. It comes with `pollora/roles` 0.2 + +### Changed +- `RoleServiceProvider` binds `RoleRegistry` with its `RoleDefinitionBuilder` and adds `RoleDeclarer`, the service the facade forwards to + ## [v13.36.1](https://github.com/Pollora/framework/compare/v13.36.0...v13.36.1) - 2026-10-16 ### Added diff --git a/src/Role/Infrastructure/Laravel/RoleServiceProvider.php b/src/Role/Infrastructure/Laravel/RoleServiceProvider.php index bdfd20b5..4754a7bf 100644 --- a/src/Role/Infrastructure/Laravel/RoleServiceProvider.php +++ b/src/Role/Infrastructure/Laravel/RoleServiceProvider.php @@ -12,12 +12,14 @@ use Pollora\Hook\Domain\Contract\Action; use Pollora\Hook\Domain\Contract\Filter; use Pollora\Role\Application\Services\CapabilityOwnerReader; +use Pollora\Role\Application\Services\RoleDeclarer; use Pollora\Role\Application\Services\RoleDefinitionBuilder; use Pollora\Role\Application\Services\RoleRegistry; use Pollora\Role\Domain\Contracts\RoleStoreInterface; use Pollora\Role\Domain\Contracts\RoleUsageInterface; use Pollora\Role\Domain\Services\PostTypeCapabilityMap; use Pollora\Role\Domain\Services\RoleCompiler; +use Pollora\Role\Infrastructure\Adapters\WordPressLateRefresh; use Pollora\Role\Infrastructure\Adapters\WordPressRoleInjector; use Pollora\Role\Infrastructure\Adapters\WordPressRoleLabelTranslator; use Pollora\Role\Infrastructure\Adapters\WordPressRoleStore; @@ -48,7 +50,6 @@ public function register(): void $this->app->singleton(CapabilityOwnerReader::class); $this->app->singleton(PostTypeCapabilityMap::class); $this->app->singleton(RoleCompiler::class); - $this->app->singleton(RoleRegistry::class); $this->app->singleton(WordPressRoleLabelTranslator::class); $this->app->singleton(RoleUsageInterface::class, WordPressRoleUsage::class); $this->app->singleton(RoleStoreInterface::class, WordPressRoleStore::class); @@ -58,6 +59,18 @@ public function register(): void $this->superRoles(), )); + // Explicit: the registry checks the fluent declarations with this builder. + $this->app->singleton(RoleRegistry::class, fn (Application $app): RoleRegistry => new RoleRegistry( + $app->make(CapabilityOwnerReader::class), + $app->make(PostTypeCapabilityMap::class), + $app->make(RoleDefinitionBuilder::class), + )); + + $this->app->singleton(RoleDeclarer::class, fn (Application $app): RoleDeclarer => new RoleDeclarer( + $app->make(RoleRegistry::class), + (new WordPressLateRefresh($app->make(WordPressRoleInjector::class)))->__invoke(...), + )); + $this->app->singleton(WordPressRoleInjector::class, fn (Application $app): WordPressRoleInjector => new WordPressRoleInjector( $app->make(RoleRegistry::class), $app->make(RoleCompiler::class), diff --git a/src/Support/Facades/Role.php b/src/Support/Facades/Role.php new file mode 100644 index 00000000..68f88eca --- /dev/null +++ b/src/Support/Facades/Role.php @@ -0,0 +1,42 @@ +inherits('author') + * ->grantsPostType(Event::class, Access::Editor) + * ->grants('export_attendees') + * ->without('publish_posts'); + * ``` + * + * @method static RoleDeclaration make(string $slug, string $label) Declare a role + * @method static RoleDeclaration modify(string $slug) Adjust a role WordPress already has + * @method static void capabilitySet(class-string|list $capabilities) Declare capabilities of the project + * + * @see RoleDeclarer + */ +class Role extends Facade +{ + /** + * Get the registered name of the component. + */ + protected static function getFacadeAccessor(): string + { + return RoleDeclarer::class; + } +} diff --git a/tests/Feature/Extraction/RolesContractTest.php b/tests/Feature/Extraction/RolesContractTest.php index 8575f5bd..5771d65c 100644 --- a/tests/Feature/Extraction/RolesContractTest.php +++ b/tests/Feature/Extraction/RolesContractTest.php @@ -7,22 +7,32 @@ use Illuminate\Filesystem\Filesystem; use Illuminate\Foundation\Application; use Illuminate\Routing\Router; +use Illuminate\Support\Facades\Facade; use Illuminate\View\Compilers\BladeCompiler; use Pollora\Hook\Domain\Contract\Action; use Pollora\Hook\Domain\Contract\Filter; +use Pollora\Role\Application\Services\CapabilityOwnerReader; +use Pollora\Role\Application\Services\RoleDeclarer; use Pollora\Role\Application\Services\RoleDefinitionBuilder; use Pollora\Role\Application\Services\RoleRegistry; +use Pollora\Role\Domain\Enums\Access; +use Pollora\Role\Domain\Services\PostTypeCapabilityMap; use Pollora\Role\Domain\Services\RoleCompiler; +use Pollora\Role\Infrastructure\Adapters\WordPressRoleInjector; use Pollora\Role\Infrastructure\Laravel\EnsureUserHasRole; use Pollora\Role\Infrastructure\Laravel\RoleServiceProvider; +use Pollora\Support\Facades\Role as RoleFacade; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Tests\Unit\Role\Fixtures\EditorAdjustments; use Tests\Unit\Role\Fixtures\Event; +use Tests\Unit\Role\Fixtures\EventCap; use Tests\Unit\Role\Fixtures\EventManager; use Tests\Unit\Role\Fixtures\Genre; use Tests\Unit\Role\Fixtures\Venue; +require_once dirname(__DIR__, 2).'/Unit/Role/Fixtures/wordpress-roles.php'; + /** * Roles declared in code, from the attributes to the capabilities WordPress stores, with the real provider, the * real Laravel container and the real compiler; only WordPress's own role storage is out of the picture (the @@ -96,3 +106,80 @@ expect($router->getMiddleware()['role'] ?? null)->toBe(EnsureUserHasRole::class) ->and($blade->compileString("@role('editor') shown @endrole"))->toContain('endif'); }); + +it('declares roles with the fluent API through the container and the facade, to the same roles as the attributes', function (): void { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($this->app); + + try { + $registry = $this->app->make(RoleRegistry::class); + $registry->addPostType(Event::class); + $registry->addPostType(Venue::class); + $registry->addTaxonomy(Genre::class); + + RoleFacade::make('event_manager', 'Event manager') + ->inherits('author') + ->grantsPostType(Event::class, Access::Editor) + ->grantsPostType('venue', Access::Author) + ->grantsTaxonomy(Genre::class) + ->grants(EventCap::ExportAttendees, 'scan_tickets') + ->grants('upload_files') + ->without('publish_posts'); + RoleFacade::modify('editor') + ->grantsPostType(Event::class, Access::Contributor) + ->grants(EventCap::ScanTickets) + ->without('edit_theme_options'); + + $declarer = $this->app->make(RoleDeclarer::class); + $viaFacade = $this->app->make(RoleCompiler::class)->compile( + $this->stored, + $registry->definitions(), + $registry->modifications(), + ['administrator'], + $registry->declaredCapabilities(), + ); + + $other = new RoleRegistry(new CapabilityOwnerReader, new PostTypeCapabilityMap); + $other->addPostType(Event::class); + $other->addPostType(Venue::class); + $other->addTaxonomy(Genre::class); + $builder = $this->app->make(RoleDefinitionBuilder::class); + $other->addRole($builder->build(EventManager::class)); + $other->addModification($builder->build(EditorAdjustments::class)); + $viaAttributes = $this->app->make(RoleCompiler::class)->compile($this->stored, $other->definitions(), $other->modifications(), ['administrator'], $other->declaredCapabilities()); + + expect($viaFacade)->toBe($viaAttributes) + ->and($declarer)->toBe($this->app->make(RoleDeclarer::class)) + ->and($registry->refusals())->toBe([]); + } finally { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + } +}); + +it('names the call in the facade that declared a role in what it refuses', function (): void { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($this->app); + + try { + RoleFacade::make('editor', 'Editor'); + + expect($this->app->make(RoleRegistry::class)->refusals()[0])->toStartWith("Role::make('editor') at ".__FILE__.':'); + } finally { + Facade::clearResolvedInstances(); + Facade::setFacadeApplication(null); + } +}); + +it('checks fluent declarations in the registry the injector compiles', function (): void { + $this->app->make(RoleDeclarer::class)->make('meddler', 'Meddler')->grants('edit_users'); + $this->app->make(RoleDeclarer::class)->make('usher', 'Usher')->inherits('subscriber'); + + $wpRoles = new WP_Roles; + $wpRoles->roles = $this->stored; + $injector = $this->app->make(WordPressRoleInjector::class); + $injector->inject($wpRoles); + + expect($wpRoles->roles)->toHaveKey('usher')->not->toHaveKey('meddler') + ->and($injector->warnings()[0])->toContain('allowSensitive: true'); +}); From 661bc6723a04af7602fec8dd0914d4b749880e52 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 16 Oct 2026 18:26:48 +0200 Subject: [PATCH 2/3] style: rector --- tests/Feature/Extraction/RolesContractTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Feature/Extraction/RolesContractTest.php b/tests/Feature/Extraction/RolesContractTest.php index 5771d65c..169db115 100644 --- a/tests/Feature/Extraction/RolesContractTest.php +++ b/tests/Feature/Extraction/RolesContractTest.php @@ -177,6 +177,7 @@ $wpRoles = new WP_Roles; $wpRoles->roles = $this->stored; + $injector = $this->app->make(WordPressRoleInjector::class); $injector->inject($wpRoles); From ab8343e5a34692a06669747556b753dd957e8836 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 16 Oct 2026 19:02:36 +0200 Subject: [PATCH 3/3] chore: require pollora/roles ^0.2 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index ef87c275..cc496f77 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ "pollora/meta": "^0.1", "pollora/hook": "^2.0.1", "pollora/option": "^1.1", - "pollora/roles": "^0.1", + "pollora/roles": "^0.2", "pollora/schedule": "^1.0", "pollora/wp-cli": "^0.1 || ^1.0", "pollora/wp-rest": "^0.1 || ^1.0",