|
| 1 | +# Roadmap — webrek/laravel-mongo-permission |
| 2 | + |
| 3 | +Drawn up 2026-05-18 after v1.0.0 shipped. Items ordered by impact vs |
| 4 | +effort. Each section is concrete enough to start work on; the small |
| 5 | +items list their own steps, the larger ones name the design decisions |
| 6 | +that still need a sit-down before coding. |
| 7 | + |
| 8 | +Versioning intent: SemVer. v1.x patches and additive features here. |
| 9 | +Anything breaking is reserved for v2.0. |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +## Track A — Differentiators (v1.1 — v1.3) |
| 14 | + |
| 15 | +These three are the reason a Laravel + MongoDB team would pick this |
| 16 | +package over `spatie/laravel-permission`. Without at least one of |
| 17 | +them, the package is "spatie-compatible on Mongo" — useful, but not |
| 18 | +distinctive. |
| 19 | + |
| 20 | +### A1. TTL / expiring grants (v1.1) |
| 21 | + |
| 22 | +**Why.** MongoDB has native TTL indexes. Adding `expires_at` on a |
| 23 | +grant subdocument and letting Mongo expire it for us is a few lines |
| 24 | +of work and a real differentiator: spatie cannot do this without a |
| 25 | +job scheduler. Use cases: temporary support-engineer access, trial |
| 26 | +upgrades, scheduled access windows, "give intern editor for 30 days". |
| 27 | + |
| 28 | +**API sketch.** |
| 29 | +```php |
| 30 | +$user->assignRole('admin', expiresAt: now()->addHours(2)); |
| 31 | +$user->givePermissionTo('publish posts', expiresAt: now()->addDays(7)); |
| 32 | +$user->hasRole('admin'); // false once the TTL has passed |
| 33 | +``` |
| 34 | + |
| 35 | +**Schema changes.** Add `expires_at: ISODate | null` to the subdocs |
| 36 | +embedded in `users.role_ids` and `users.permission_ids`: |
| 37 | + |
| 38 | +```js |
| 39 | +{ role_id: ObjectId, team_id: ObjectId|null, expires_at: ISODate|null } |
| 40 | +``` |
| 41 | + |
| 42 | +**Filtering.** `HasRoles::roles()` and `HasPermissions::permissions()` |
| 43 | +filter out subdocs where `expires_at !== null && expires_at <= now()`. |
| 44 | +Cache slug arrays already get rebuilt on attach/detach events — |
| 45 | +adding a per-grant filter is a one-liner inside `PermissionRegistrar`. |
| 46 | + |
| 47 | +**Cleanup options (pick one before coding).** |
| 48 | +1. **App-side filter only.** Expired grants stay in the document |
| 49 | + forever but never match. Simplest. Documents grow over time. |
| 50 | +2. **Background prune command.** `permission:prune-expired` runs |
| 51 | + `updateMany` with `$pull` against `expires_at <= now()`. Manual |
| 52 | + cron. |
| 53 | +3. **TTL index on a separate `grants` collection.** Move grants out |
| 54 | + of the user doc into a collection with `{expireAfterSeconds: 0}` |
| 55 | + index on `expires_at`. Mongo prunes automatically. Breaks the |
| 56 | + embedded model; bigger refactor. |
| 57 | + |
| 58 | +Recommendation: **option 1 + provide option 2 as `permission:prune-expired`**. |
| 59 | +TTL collection (option 3) is a v2 conversation — it touches the |
| 60 | +fundamental data model. |
| 61 | + |
| 62 | +**Events.** Reuse `RoleDetached` / `PermissionDetached` when option 2 |
| 63 | +prunes; add `?\DateTimeInterface $expiredAt` to the payload to let |
| 64 | +audit listeners distinguish manual revocations from TTL expiry. |
| 65 | + |
| 66 | +**Tests.** Travel time with `Carbon::setTestNow()`: |
| 67 | +- assign → check before expiry → true |
| 68 | +- assign → travel past expiry → check → false |
| 69 | +- prune command → grant subdoc removed |
| 70 | +- expired grant does not block re-assignment with new TTL |
| 71 | + |
| 72 | +**Effort.** ~1 day. ~200 LOC + 8–10 tests. |
| 73 | + |
| 74 | +--- |
| 75 | + |
| 76 | +### A2. Role hierarchy / inheritance (v1.2) |
| 77 | + |
| 78 | +**Why.** The #1 open request on `spatie/laravel-permission` for years. |
| 79 | +A real hierarchy ("admin inherits editor inherits viewer") cuts |
| 80 | +permission catalogs by orders of magnitude in mature apps. |
| 81 | + |
| 82 | +**API sketch.** |
| 83 | +```php |
| 84 | +$editor->inheritsFrom($viewer); // editor gets viewer's perms |
| 85 | +$admin->inheritsFrom($editor); // and transitively viewer's |
| 86 | +$admin->getAllPermissions(); // walks the inheritance graph |
| 87 | +``` |
| 88 | + |
| 89 | +**Schema changes.** Add `parent_role_ids: ObjectId[]` to the `roles` |
| 90 | +collection. Multi-parent inheritance allowed (diamond is fine, cycles |
| 91 | +are not). |
| 92 | + |
| 93 | +**Cycle detection.** On `inheritsFrom`, run a BFS from the new parent |
| 94 | +walking `parent_role_ids` looking for `$this->id`. Throw |
| 95 | +`RoleHierarchyCycle` if found. Cache the closure. |
| 96 | + |
| 97 | +**Permission resolution.** When building the cached slug array for a |
| 98 | +user, recursively flatten each role's permissions through its parent |
| 99 | +chain. Memoize per-role transitive permission slug set, invalidate on |
| 100 | +the same `PermissionAttached`/`Detached` events plus a new |
| 101 | +`RoleParentChanged`. |
| 102 | + |
| 103 | +**Decisions to make before coding.** |
| 104 | +1. Single-parent vs multi-parent inheritance? Single is simpler and |
| 105 | + ~95% of users only need it. Recommendation: **multi-parent** with |
| 106 | + strict cycle detection. Costs nothing extra in Mongo terms. |
| 107 | +2. Should removing a parent role from a user revoke transitively- |
| 108 | + granted permissions instantly? Yes — invalidate user cache on the |
| 109 | + role's parent change. |
| 110 | +3. Should `hasDirectPermission` see transitive grants? **No.** Direct |
| 111 | + means "attached directly to the user", transitive permissions go |
| 112 | + through `hasPermissionTo` only. |
| 113 | + |
| 114 | +**New errors.** `RoleHierarchyCycle`, `RoleHierarchyTooDeep` |
| 115 | +(configurable max depth, default 5 to bound resolution time). |
| 116 | + |
| 117 | +**New event.** `RoleParentChanged { Role $role, Role $parent, string |
| 118 | +$action /* attached|detached */ }`. |
| 119 | + |
| 120 | +**Tests.** |
| 121 | +- Diamond inheritance resolves once (no duplicate slugs) |
| 122 | +- Cycle throws on second inheritance call |
| 123 | +- Max depth respected |
| 124 | +- Cache invalidation: changing parent's perms reflects in child users |
| 125 | + |
| 126 | +**Effort.** ~2-3 days. ~400 LOC + ~15 tests. |
| 127 | + |
| 128 | +--- |
| 129 | + |
| 130 | +### A3. Migration command from spatie/laravel-permission (v1.3) |
| 131 | + |
| 132 | +**Why.** Without this, no production team on spatie will move. With |
| 133 | +it, you open the entire spatie user base as potential adopters. The |
| 134 | +spec calls this out as a "future companion package"; the simplest |
| 135 | +form is one Artisan command living in this repo. |
| 136 | + |
| 137 | +**Scope.** One-way migration: SQL → Mongo. Idempotent (re-runnable). |
| 138 | +Dry-run flag for safety. |
| 139 | + |
| 140 | +**Command shape.** |
| 141 | +```bash |
| 142 | +php artisan permission:migrate-from-spatie \ |
| 143 | + --connection=mysql \ |
| 144 | + --user-model="App\\Models\\User" \ |
| 145 | + --user-mongo-collection=users \ |
| 146 | + --dry-run |
| 147 | +``` |
| 148 | + |
| 149 | +**What it reads.** |
| 150 | +- `roles`, `permissions`, `role_has_permissions`, `model_has_roles`, |
| 151 | + `model_has_permissions`. The five canonical spatie tables. |
| 152 | +- For teams: also `team_id` from those pivot tables when |
| 153 | + `permission.teams = true` in the spatie config. |
| 154 | + |
| 155 | +**What it writes.** |
| 156 | +1. For each `permissions` row → upsert into mongo `permissions` |
| 157 | + collection by `(name, guard_name, team_id)`. |
| 158 | +2. Same for `roles`, then resolve `role_has_permissions` and write |
| 159 | + `permission_ids` array. |
| 160 | +3. For each user in `model_has_roles` / `model_has_permissions`: |
| 161 | + resolve the user by external ID (provide a mapper closure via |
| 162 | + command argument or a published config), push the appropriate |
| 163 | + subdoc into `role_ids` / `permission_ids`. |
| 164 | + |
| 165 | +**Decisions to make.** |
| 166 | +1. ID mapping. Spatie uses int PKs; Mongo uses ObjectId. The command |
| 167 | + needs a way to look up the Mongo user from the SQL user — usually |
| 168 | + by `email` or a `legacy_id` field. Make this a config callback. |
| 169 | +2. Conflict handling. If a permission with the same name+guard+team |
| 170 | + already exists in Mongo, do we overwrite or skip? Default |
| 171 | + **skip**, with a `--force` flag to overwrite. |
| 172 | +3. Guard mapping. Spatie's `guard_name` ports 1:1. |
| 173 | + |
| 174 | +**Tests.** Spin up SQLite in addition to Mongo (already in |
| 175 | +testbench), seed spatie tables, run the command, assert Mongo state. |
| 176 | + |
| 177 | +**Effort.** ~3 days. ~500 LOC + integration tests. |
| 178 | + |
| 179 | +--- |
| 180 | + |
| 181 | +## Track B — Adoption polish (v1.1, parallel) |
| 182 | + |
| 183 | +Small, low-risk, high-visibility. Land them alongside A1. |
| 184 | + |
| 185 | +### B1. composer.json keywords + support URLs |
| 186 | + |
| 187 | +Pad `composer.json` with: |
| 188 | +```json |
| 189 | +{ |
| 190 | + "keywords": ["laravel", "mongodb", "permissions", "roles", "rbac", |
| 191 | + "acl", "authorization", "multi-tenant", "wildcard"], |
| 192 | + "support": { |
| 193 | + "issues": "https://github.com/webrek/laravel-mongo-permission/issues", |
| 194 | + "source": "https://github.com/webrek/laravel-mongo-permission" |
| 195 | + } |
| 196 | +} |
| 197 | +``` |
| 198 | +Packagist surfaces these in search. ~10 minutes. |
| 199 | + |
| 200 | +### B2. CHANGELOG.md (Keep-a-Changelog) |
| 201 | + |
| 202 | +One file at repo root. Backfill v1.0.0 entry from the section list |
| 203 | +already in the tag message. Going forward, add `## [Unreleased]` |
| 204 | +section at the top and move entries into a version block on each |
| 205 | +tag. |
| 206 | + |
| 207 | +### B3. README "Why this vs spatie" section |
| 208 | + |
| 209 | +Honest comparison, not marketing. Bullets: |
| 210 | +- Multi-tenant `team_id` flows through every read/write natively |
| 211 | +- Events carry `team_id` and `guard` for tenant-scoped audit |
| 212 | +- No pivot tables — grants embedded in user doc, one read per check |
| 213 | +- Cache key tuple `(user_id, team_id)` matches the access pattern |
| 214 | +- MongoDB-native: required, not optional |
| 215 | + |
| 216 | +End with "If your stack is SQL, use spatie." That builds trust. |
| 217 | +Insert after `## Status`, before `## Caching`. |
| 218 | + |
| 219 | +--- |
| 220 | + |
| 221 | +## Track C — Quality scaffolding (v1.2, parallel) |
| 222 | + |
| 223 | +Visible quality signals for OSS adopters. |
| 224 | + |
| 225 | +### C1. PHPStan level 6 in CI |
| 226 | + |
| 227 | +Add `phpstan/phpstan: ^1.10` to require-dev, a `phpstan.neon` at |
| 228 | +repo root targeting `src/` at level 6, and a new job in |
| 229 | +`.github/workflows/ci.yml`: |
| 230 | + |
| 231 | +```yaml |
| 232 | +analyse: |
| 233 | + runs-on: ubuntu-latest |
| 234 | + steps: |
| 235 | + - uses: actions/checkout@v5 |
| 236 | + - uses: shivammathur/setup-php@v2 |
| 237 | + with: { php-version: '8.3', extensions: mongodb } |
| 238 | + - run: composer install --no-progress |
| 239 | + - run: vendor/bin/phpstan analyse --no-progress |
| 240 | +``` |
| 241 | +
|
| 242 | +Level 6 catches "no missing type hints" without the false positives |
| 243 | +of 8/9. Adjust per finding. ~2 hours including fixes. |
| 244 | +
|
| 245 | +### C2. Test helpers trait |
| 246 | +
|
| 247 | +`src/Testing/MongoPermissionAssertions.php`: |
| 248 | + |
| 249 | +```php |
| 250 | +trait MongoPermissionAssertions |
| 251 | +{ |
| 252 | + public function assertUserHasRole($user, string $role, ?string $guard = null): void; |
| 253 | + public function assertUserDoesNotHaveRole($user, string $role): void; |
| 254 | + public function assertUserHasPermission($user, string $perm): void; |
| 255 | + public function assertUserHasDirectPermission($user, string $perm): void; |
| 256 | + public function assertRoleHasPermission($role, string $perm): void; |
| 257 | +} |
| 258 | +``` |
| 259 | + |
| 260 | +Consumer apps `use` the trait in their TestCase. Pure ergonomic |
| 261 | +wrappers around the trait methods. ~1 hour. |
| 262 | + |
| 263 | +--- |
| 264 | + |
| 265 | +## Track D — Operational commands (v1.2) |
| 266 | + |
| 267 | +Low effort, real ops value. Land both in one commit. |
| 268 | + |
| 269 | +### D1. permission:list-users |
| 270 | + |
| 271 | +```bash |
| 272 | +php artisan permission:list-users admin |
| 273 | +php artisan permission:list-users --permission="edit articles" |
| 274 | +php artisan permission:list-users admin --team=acme --guard=api |
| 275 | +``` |
| 276 | + |
| 277 | +Mongo query against the user collection by `role_ids.role_id` (or |
| 278 | +`permission_ids.permission_id`). Table output: id, name, email, |
| 279 | +team, direct?/via-role-name. |
| 280 | + |
| 281 | +User model resolution: take a class via config |
| 282 | +`permission.user_model` (we should add this anyway). Falls back to |
| 283 | +`config('auth.providers.users.model')`. |
| 284 | + |
| 285 | +### D2. permission:check |
| 286 | + |
| 287 | +```bash |
| 288 | +php artisan permission:check {user_id} {permission} [--guard=] |
| 289 | +``` |
| 290 | + |
| 291 | +Output a trace of WHY the answer is what it is: |
| 292 | + |
| 293 | +``` |
| 294 | +User 65f... has 'edit articles'? YES |
| 295 | + ✓ direct grant: id=64a..., team_id=null |
| 296 | + ✓ via role 'editor' (id=64b..., team_id=null) |
| 297 | + ✗ wildcard 'posts.*' does not imply 'edit articles' |
| 298 | +``` |
| 299 | + |
| 300 | +Debugging gold. ~3 hours. |
| 301 | + |
| 302 | +--- |
| 303 | + |
| 304 | +## Track E — Companion package (future, separate repo) |
| 305 | + |
| 306 | +### E1. Filament v3/v4 adapter |
| 307 | + |
| 308 | +`webrek/laravel-mongo-permission-filament` as a sibling repo. |
| 309 | +Provides: |
| 310 | +- Resource pages for Role, Permission with policies wired |
| 311 | +- A "User permissions" relation manager for the User resource |
| 312 | +- A "Team scope" filter on the panel |
| 313 | +- Form fields with the wildcard warning baked in |
| 314 | + |
| 315 | +Effort: ~1 week, mostly UI. Out of this repo's scope; track it |
| 316 | +elsewhere. |
| 317 | + |
| 318 | +--- |
| 319 | + |
| 320 | +## Suggested sequence |
| 321 | + |
| 322 | +``` |
| 323 | +v1.1 → A1 (TTL) + B1 + B2 + B3 |
| 324 | +v1.2 → A2 (Hierarchy) + C1 + C2 + D1 + D2 |
| 325 | +v1.3 → A3 (Spatie migration) |
| 326 | +v1.4+ → E1 (Filament) as separate repo |
| 327 | +``` |
| 328 | +
|
| 329 | +Each minor version stays additive — no breaking changes — so adopters |
| 330 | +can upgrade without ceremony. |
| 331 | +
|
| 332 | +## Explicit non-goals (don't reopen) |
| 333 | +
|
| 334 | +- **Driver abstraction layer.** Married to MongoDB by design (spec §16). |
| 335 | +- **Built-in admin UI.** Filament/Nova are the answer; adapters yes, |
| 336 | + UI in core no. |
| 337 | +- **`HasTeams` trait.** The package consumes the active team, does |
| 338 | + not own it (spec §16). |
| 339 | +- **Built-in soft deletes.** Trivial for consumers to extend; not |
| 340 | + worth the default complexity. |
0 commit comments