Skip to content

Commit 038e9f4

Browse files
committed
Add roadmap for v1.1 through v1.4
Captures the post-v1.0 review: three differentiators (TTL grants, role hierarchy, spatie migration), low-effort adoption polish (composer metadata, changelog, README why-section), quality scaffolding (PHPStan, test helpers), two operational commands, and a future Filament adapter. Each item names the design decisions still pending so work can start without re-litigating scope.
1 parent 1df99d7 commit 038e9f4

1 file changed

Lines changed: 340 additions & 0 deletions

File tree

docs/roadmap.md

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
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

Comments
 (0)