Skip to content

Commit b951179

Browse files
authored
feat: update class gen to use code-first config paths (#31)
Support for using configured code-first paths in class generation commands. When users configure custom paths for roles, permissions, and capabilities via the mandate.code_first.paths configuration, the generator commands will now use those paths to determine the correct namespace for the generated classes.
1 parent 6166738 commit b951179

File tree

9 files changed

+280
-1
lines changed

9 files changed

+280
-1
lines changed

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,6 +1103,23 @@ The `--seed` flag will **automatically create** any roles, permissions, or capab
11031103
- **Code-first enabled**: Syncs PHP class definitions to database first, then seeds assignments
11041104
- **Code-first disabled**: Only seeds assignments (useful for database-only workflows)
11051105

1106+
#### Wildcard Assignments (Super Admin)
1107+
1108+
Use `['*']` to assign **all existing permissions or capabilities** to a role:
1109+
1110+
```php
1111+
use App\Roles\SystemRoles;
1112+
1113+
'assignments' => [
1114+
SystemRoles::SUPER_ADMIN => [
1115+
'permissions' => ['*'], // Assigns ALL permissions
1116+
'capabilities' => ['*'], // Assigns ALL capabilities
1117+
],
1118+
],
1119+
```
1120+
1121+
This is useful for super admin roles that should have access to everything. The wildcard assigns all permissions/capabilities that exist in the database at sync time, so make sure to sync your definitions first (or run the full `mandate:sync --seed` which syncs definitions before seeding assignments).
1122+
11061123
### Label and Description Columns
11071124

11081125
To store labels and descriptions in the database, publish and run the metadata migration:

pint.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"import_constants": true,
2020
"import_functions": true
2121
},
22-
"mb_str_functions": true,
22+
"mb_str_functions": false,
2323
"modernize_types_casting": true,
2424
"new_with_parentheses": false,
2525
"no_superfluous_elseif": true,
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OffloadProject\Mandate\Commands\Concerns;
6+
7+
use Illuminate\Support\Str;
8+
9+
/**
10+
* Provides shared functionality for resolving configured paths to namespaces.
11+
*
12+
* Used by generator commands to respect the paths defined in config('mandate.code_first.paths').
13+
*/
14+
trait ResolvesConfiguredPaths
15+
{
16+
/**
17+
* Convert a filesystem path to a PSR-4 namespace.
18+
*
19+
* Handles paths both inside and outside the app directory by checking
20+
* the application's PSR-4 autoload configuration.
21+
*/
22+
protected function pathToNamespace(string $path): string
23+
{
24+
$path = rtrim($path, DIRECTORY_SEPARATOR);
25+
26+
// Try app directory first (most common case)
27+
$appPath = $this->laravel->basePath('app');
28+
29+
if (str_starts_with($path, $appPath)) {
30+
$relativePath = mb_substr($path, mb_strlen($appPath));
31+
$relativePath = trim($relativePath, DIRECTORY_SEPARATOR);
32+
$namespace = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
33+
34+
return trim($this->laravel->getNamespace().$namespace, '\\');
35+
}
36+
37+
// For paths outside app directory, try to resolve from composer autoload
38+
$basePath = $this->laravel->basePath();
39+
40+
if (str_starts_with($path, $basePath)) {
41+
$relativePath = mb_substr($path, mb_strlen($basePath));
42+
$relativePath = trim($relativePath, DIRECTORY_SEPARATOR);
43+
44+
// Check composer.json for PSR-4 mappings
45+
$namespace = $this->resolveNamespaceFromComposer($relativePath);
46+
47+
if ($namespace !== null) {
48+
return $namespace;
49+
}
50+
51+
// Fall back to converting path directly (e.g., src/Permissions -> Src\Permissions)
52+
return str_replace(DIRECTORY_SEPARATOR, '\\', Str::studly($relativePath));
53+
}
54+
55+
// Absolute path outside project - use the path segments as namespace
56+
$segments = explode(DIRECTORY_SEPARATOR, $path);
57+
$relevantSegments = array_slice($segments, -2); // Take last 2 segments
58+
59+
return implode('\\', array_map([Str::class, 'studly'], $relevantSegments));
60+
}
61+
62+
/**
63+
* Attempt to resolve namespace from composer.json PSR-4 autoload config.
64+
*/
65+
protected function resolveNamespaceFromComposer(string $relativePath): ?string
66+
{
67+
$composerPath = $this->laravel->basePath('composer.json');
68+
69+
if (! file_exists($composerPath)) {
70+
return null;
71+
}
72+
73+
$contents = file_get_contents($composerPath);
74+
75+
if ($contents === false) {
76+
return null;
77+
}
78+
79+
$composer = json_decode($contents, true);
80+
$autoload = $composer['autoload']['psr-4'] ?? [];
81+
82+
foreach ($autoload as $namespace => $paths) {
83+
$paths = (array) $paths;
84+
85+
foreach ($paths as $autoloadPath) {
86+
$autoloadPath = trim($autoloadPath, '/');
87+
88+
if (str_starts_with($relativePath, $autoloadPath)) {
89+
$remainder = mb_substr($relativePath, mb_strlen($autoloadPath));
90+
$remainder = trim($remainder, DIRECTORY_SEPARATOR);
91+
$namespaceSuffix = str_replace(DIRECTORY_SEPARATOR, '\\', $remainder);
92+
93+
return trim($namespace.$namespaceSuffix, '\\');
94+
}
95+
}
96+
}
97+
98+
return null;
99+
}
100+
}

src/Commands/MakeCapabilityCommand.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Illuminate\Console\GeneratorCommand;
88
use Illuminate\Support\Str;
9+
use OffloadProject\Mandate\Commands\Concerns\ResolvesConfiguredPaths;
910
use OffloadProject\Mandate\Guard;
1011
use OffloadProject\Mandate\Models\Capability;
1112
use OffloadProject\Mandate\Models\Permission;
@@ -23,6 +24,8 @@
2324
#[AsCommand(name: 'mandate:capability')]
2425
final class MakeCapabilityCommand extends GeneratorCommand
2526
{
27+
use ResolvesConfiguredPaths;
28+
2629
protected $name = 'mandate:capability';
2730

2831
protected $description = 'Create a new capability class or database record';
@@ -117,6 +120,12 @@ protected function getStub(): string
117120

118121
protected function getDefaultNamespace($rootNamespace): string
119122
{
123+
$configuredPath = config('mandate.code_first.paths.capabilities');
124+
125+
if ($configuredPath) {
126+
return $this->pathToNamespace($configuredPath);
127+
}
128+
120129
return $rootNamespace.'\\Capabilities';
121130
}
122131

src/Commands/MakePermissionCommand.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Illuminate\Console\GeneratorCommand;
88
use Illuminate\Support\Str;
9+
use OffloadProject\Mandate\Commands\Concerns\ResolvesConfiguredPaths;
910
use OffloadProject\Mandate\Guard;
1011
use OffloadProject\Mandate\Models\Permission;
1112
use Symfony\Component\Console\Attribute\AsCommand;
@@ -21,6 +22,8 @@
2122
#[AsCommand(name: 'mandate:permission')]
2223
final class MakePermissionCommand extends GeneratorCommand
2324
{
25+
use ResolvesConfiguredPaths;
26+
2427
protected $name = 'mandate:permission';
2528

2629
protected $description = 'Create a new permission class or database record';
@@ -83,6 +86,12 @@ protected function getStub(): string
8386

8487
protected function getDefaultNamespace($rootNamespace): string
8588
{
89+
$configuredPath = config('mandate.code_first.paths.permissions');
90+
91+
if ($configuredPath) {
92+
return $this->pathToNamespace($configuredPath);
93+
}
94+
8695
return $rootNamespace.'\\Permissions';
8796
}
8897

src/Commands/MakeRoleCommand.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Illuminate\Console\GeneratorCommand;
88
use Illuminate\Support\Str;
9+
use OffloadProject\Mandate\Commands\Concerns\ResolvesConfiguredPaths;
910
use OffloadProject\Mandate\Guard;
1011
use OffloadProject\Mandate\Models\Permission;
1112
use OffloadProject\Mandate\Models\Role;
@@ -23,6 +24,8 @@
2324
#[AsCommand(name: 'mandate:role')]
2425
final class MakeRoleCommand extends GeneratorCommand
2526
{
27+
use ResolvesConfiguredPaths;
28+
2629
protected $name = 'mandate:role';
2730

2831
protected $description = 'Create a new role class or database record';
@@ -108,6 +111,12 @@ protected function getStub(): string
108111

109112
protected function getDefaultNamespace($rootNamespace): string
110113
{
114+
$configuredPath = config('mandate.code_first.paths.roles');
115+
116+
if ($configuredPath) {
117+
return $this->pathToNamespace($configuredPath);
118+
}
119+
111120
return $rootNamespace.'\\Roles';
112121
}
113122

tests/Feature/Commands/MakeCapabilityCommandTest.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,49 @@
7676

7777
expect(File::exists(app_path('Capabilities/TestCapabilities.php')))->toBeTrue();
7878
});
79+
80+
it('generates in custom configured path', function () {
81+
$customPath = app_path('Authorization/Capabilities');
82+
83+
// Set custom path in config
84+
config(['mandate.code_first.paths.capabilities' => $customPath]);
85+
86+
$this->artisan('mandate:capability', ['name' => 'TestCapabilities'])
87+
->assertSuccessful();
88+
89+
expect(File::exists($customPath.'/TestCapabilities.php'))->toBeTrue();
90+
91+
$content = File::get($customPath.'/TestCapabilities.php');
92+
expect($content)->toContain('namespace App\\Authorization\\Capabilities;');
93+
94+
// Clean up
95+
File::delete($customPath.'/TestCapabilities.php');
96+
if (File::isDirectory($customPath) && count(File::files($customPath)) === 0) {
97+
File::deleteDirectory($customPath);
98+
}
99+
$authDir = app_path('Authorization');
100+
if (File::isDirectory($authDir) && count(File::allFiles($authDir)) === 0) {
101+
File::deleteDirectory($authDir);
102+
}
103+
});
104+
105+
it('generates correct namespace for nested custom path', function () {
106+
$customPath = app_path('Domain/Auth/Capabilities');
107+
108+
config(['mandate.code_first.paths.capabilities' => $customPath]);
109+
110+
$this->artisan('mandate:capability', ['name' => 'TestCapabilities'])
111+
->assertSuccessful();
112+
113+
expect(File::exists($customPath.'/TestCapabilities.php'))->toBeTrue();
114+
115+
$content = File::get($customPath.'/TestCapabilities.php');
116+
expect($content)->toContain('namespace App\\Domain\\Auth\\Capabilities;');
117+
118+
// Clean up
119+
File::delete($customPath.'/TestCapabilities.php');
120+
File::deleteDirectory(app_path('Domain/Auth/Capabilities'));
121+
File::deleteDirectory(app_path('Domain/Auth'));
122+
File::deleteDirectory(app_path('Domain'));
123+
});
79124
})->skip(fn () => ! class_exists(Illuminate\Console\GeneratorCommand::class), 'GeneratorCommand not available');

tests/Feature/Commands/MakePermissionCommandTest.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,49 @@
5454
expect($content)->toContain('const UPDATE');
5555
expect($content)->toContain('const DELETE');
5656
});
57+
58+
it('generates in custom configured path', function () {
59+
$customPath = app_path('Authorization/Permissions');
60+
61+
// Set custom path in config
62+
config(['mandate.code_first.paths.permissions' => $customPath]);
63+
64+
$this->artisan('mandate:permission', ['name' => 'TestPermissions'])
65+
->assertSuccessful();
66+
67+
expect(File::exists($customPath.'/TestPermissions.php'))->toBeTrue();
68+
69+
$content = File::get($customPath.'/TestPermissions.php');
70+
expect($content)->toContain('namespace App\\Authorization\\Permissions;');
71+
72+
// Clean up
73+
File::delete($customPath.'/TestPermissions.php');
74+
if (File::isDirectory($customPath) && count(File::files($customPath)) === 0) {
75+
File::deleteDirectory($customPath);
76+
}
77+
$authDir = app_path('Authorization');
78+
if (File::isDirectory($authDir) && count(File::allFiles($authDir)) === 0) {
79+
File::deleteDirectory($authDir);
80+
}
81+
});
82+
83+
it('generates correct namespace for nested custom path', function () {
84+
$customPath = app_path('Domain/Auth/Permissions');
85+
86+
config(['mandate.code_first.paths.permissions' => $customPath]);
87+
88+
$this->artisan('mandate:permission', ['name' => 'TestPermissions'])
89+
->assertSuccessful();
90+
91+
expect(File::exists($customPath.'/TestPermissions.php'))->toBeTrue();
92+
93+
$content = File::get($customPath.'/TestPermissions.php');
94+
expect($content)->toContain('namespace App\\Domain\\Auth\\Permissions;');
95+
96+
// Clean up
97+
File::delete($customPath.'/TestPermissions.php');
98+
File::deleteDirectory(app_path('Domain/Auth/Permissions'));
99+
File::deleteDirectory(app_path('Domain/Auth'));
100+
File::deleteDirectory(app_path('Domain'));
101+
});
57102
})->skip(fn () => ! class_exists(Illuminate\Console\GeneratorCommand::class), 'GeneratorCommand not available');

tests/Feature/Commands/MakeRoleCommandTest.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,49 @@
7878

7979
expect(File::exists(app_path('Roles/TestRoles.php')))->toBeTrue();
8080
});
81+
82+
it('generates in custom configured path', function () {
83+
$customPath = app_path('Authorization/Roles');
84+
85+
// Set custom path in config
86+
config(['mandate.code_first.paths.roles' => $customPath]);
87+
88+
$this->artisan('mandate:role', ['name' => 'TestRoles'])
89+
->assertSuccessful();
90+
91+
expect(File::exists($customPath.'/TestRoles.php'))->toBeTrue();
92+
93+
$content = File::get($customPath.'/TestRoles.php');
94+
expect($content)->toContain('namespace App\\Authorization\\Roles;');
95+
96+
// Clean up
97+
File::delete($customPath.'/TestRoles.php');
98+
if (File::isDirectory($customPath) && count(File::files($customPath)) === 0) {
99+
File::deleteDirectory($customPath);
100+
}
101+
$authDir = app_path('Authorization');
102+
if (File::isDirectory($authDir) && count(File::allFiles($authDir)) === 0) {
103+
File::deleteDirectory($authDir);
104+
}
105+
});
106+
107+
it('generates correct namespace for nested custom path', function () {
108+
$customPath = app_path('Domain/Auth/Roles');
109+
110+
config(['mandate.code_first.paths.roles' => $customPath]);
111+
112+
$this->artisan('mandate:role', ['name' => 'TestRoles'])
113+
->assertSuccessful();
114+
115+
expect(File::exists($customPath.'/TestRoles.php'))->toBeTrue();
116+
117+
$content = File::get($customPath.'/TestRoles.php');
118+
expect($content)->toContain('namespace App\\Domain\\Auth\\Roles;');
119+
120+
// Clean up
121+
File::delete($customPath.'/TestRoles.php');
122+
File::deleteDirectory(app_path('Domain/Auth/Roles'));
123+
File::deleteDirectory(app_path('Domain/Auth'));
124+
File::deleteDirectory(app_path('Domain'));
125+
});
81126
})->skip(fn () => ! class_exists(Illuminate\Console\GeneratorCommand::class), 'GeneratorCommand not available');

0 commit comments

Comments
 (0)