godisco/acltree-bundle
godisco/acltree-bundle brings hierarchical ACLs to Symfony applications, letting you model resources and permissions as a tree and query access at any node through a lightweight, extensible API. Ideal for content hierarchies, nested categories, and inherited permissions.
Step zero: accept that this is not installable. composer require godisco/acltree-bundle will not give you a working Laravel dependency. It is a Symfony2 bundle hard-wired to the Symfony DI container, Doctrine ORM metadata, and the deprecated symfony/security-acl component (split from the framework in 2.8, deprecated in 3.0, removed in 4.0). On Laravel you have no AppKernel, no security.acl.connection, and no acl_entries/acl_object_identities schema. The bundle is a design document you read, not a package you run.
What to read first, in order
parent/children contract the bundle expects your entity to implement — this is the whole point: the ACL subject is a tree node.Resources/config/services.yml that wraps ACL lookups with an ancestor walk. The composition of "traverse up the tree" + "resolve ACL on the first node that has an entry" is the reusable idea.The Laravel-native equivalent you build instead
composer require kalnoy/nestedset spatie/laravel-permission
// app/Models/Folder.php
use Kalnoy\Nestedset\NodeTrait;
use Illuminate\Database\Eloquent\Model;
class Folder extends Model
{
use NodeTrait; // _lft, _rgt, parent_id, getLftName()/getRgtName()
protected $fillable = ['name', 'parent_id'];
protected $guarded = [];
}
// migration: the one schema idea worth lifting from the bundle
Schema::create('folder_permissions', function (Blueprint $t) {
$t->id();
$t->foreignId('folder_id')->constrained()->cascadeOnDelete();
$t->foreignId('role_id')->constrained();
$t->string('ability'); // 'view', 'edit', 'delete'
$t->boolean('inherit')->default(true); // mirrors the bundle's cascade flag
$t->timestamps();
$t->unique(['folder_id', 'role_id', 'ability']);
});
First use case — smallest thing that proves the pattern
One root Folder and one child. Grant view to role editor on the root with inherit = true. Assert that a user with editor can view the child, and that revoking on the root revokes on the child. That two-node test is the entire value proposition; build it before anything else and keep it in CI forever.
// app/Support/PermissionResolver.php
namespace App\Support;
use App\Models\Folder;
use App\Models\User;
use Illuminate\Support\Collection;
class PermissionResolver
{
/** @var array<string, Collection> per-request memo */
private array $memo = [];
public function abilitiesFor(User $user, Folder $node): Collection
{
$key = $user->id.':'.$node->getKey();
if (isset($this->memo[$key])) {
return $this->memo[$key];
}
// One query: all nodes from root to this node.
$ancestorIds = $node->ancestorsAndSelf()->pluck('id');
$grants = \DB::table('folder_permissions')
->join('model_has_roles', 'model_has_roles.role_id', '=', 'folder_permissions.role_id')
->where('model_has_roles.model_id', $user->id)
->where('model_has_roles.model_type', $user->getMorphClass())
->whereIn('folder_permissions.folder_id', $ancestorIds)
->get(['folder_permissions.folder_id', 'folder_permissions.ability', 'folder_permissions.inherit']);
// Only grants on ancestors with inherit=true (or on the node itself) count.
$effective = $grants
->filter(fn ($g) => $g->folder_id == $node->getKey() || $g->inherit)
->pluck('ability')
->unique()
->values();
return $this->memo[$key] = $effective;
}
}
Then bind it as a singleton scoped to the request and gate on it:
// app/Providers/AuthServiceProvider.php
Gate::define('view', fn (User $u, Folder $f) => app(PermissionResolver::class)
->abilitiesFor($u, $f)->contains('view'));
That is your day-to-day workflow: the bundle tells you what inheritance means; your resolver owns how.
Choose the tree storage deliberately. The bundle is nested-set-ish and gives you the read-heavy assumption for free; make that assumption explicit instead of copying it blindly.
kalnoy/nestedset) — O(1) subtree reads, ancestor reads via _lft/_rgt. Every insert/move rewrites a range of rows. Correct for taxonomies, CMS categories, folder trees that are read far more than restructured.staudenmeir/laravel-adjacency-list) — cheap writes, ancestor walks cost a query. Correct for org charts where teams get re-parented weekly. Postgres and MySQL 8 only.Pick from the read/write ratio, not from what the bundle did.
The single-resolver pattern. One service computes effective abilities for (user, node) and everything else — Gates, Policies, Blade, API resources — calls it. Do not let ACL logic leak into controllers. The bundle's reusable contribution is precisely this separation: hierarchy traversal is one concern, permission resolution is another, and they meet in one object.
Resolve listings in bulk, never per row. The classic regression when porting this idea is calling can('view', $folder) inside a @foreach. That is N queries per page. Instead load all ancestor-granted abilities for the set of visible nodes in one pass:
$folders = Folder::with('ancestors')->paginate(50);
$visible = $folders->filter(function ($f) use ($user, $resolver) {
return $resolver->abilitiesFor($user, $f)->contains('view');
});
For real workloads, resolve the user's effective grant set once (one query over folder_permissions joined to roles) and then intersect in PHP against the subtree IDs you already have from _lft/_rgt. That is WHERE _lft >= ? AND _rgt <= ? plus a set intersection — O(1) queries.
Pick explicit inheritance semantics and encode them once. The bundle leaves you to infer the rule; your version must state it in code and tests. The two defensible options:
Whichever you pick, write it as a test data provider before you write the resolver. Denies are the hard part: if you support deny, decide deny-overrides-allow globally and never special-case it. This is a security-critical path — a resolver bug is silent privilege escalation, not a 500.
Cache the effective set, invalidate on tree events, not TTL. Key on user:{id}:node:{id} or role:{id}:node:{id}, store in Redis, and bust on Folder::saved/deleted and on node_moved. Tie invalidation to model events so a re-parent doesn't leave a user with access to a subtree they were moved out of. If the tree is large and churny, queue the invalidation for descendants.
Policy layer over resolver. Keep Laravel idioms at the edge so the rest of the app looks like a normal Laravel app:
class FolderPolicy
{
public function view(User $u, Folder $f): bool
{
return app(PermissionResolver::class)->abilitiesFor($u, $f)->contains('view');
}
}
Now $this->authorize('view', $folder), @can('view', $folder), and form requests all work without knowing the tree exists. That's the integration win the Symfony bundle never gives you in Laravel.
Admin and system bypass belong in Gate::before, not in the resolver. Otherwise every test needs a fake superuser and every query grows an OR role = 'admin' clause.
Do not try to shim the Symfony ACL mask model. The bundle speaks bitmasks (VIEW, EDIT, DELETE, OWNER), acl_object_identities, and acl_entries. That model was removed from Symfony for performance and complexity reasons. Your folder_permissions pivot with string abilities is not a lesser version — it is the version the ecosystem settled on. Skip the compatibility effort; map masks to ability names once, in a seeder, if you are migrating data.
Nested-set corruption is silent and catastrophic. _lft/_rgt drift the moment you insertGetId a row with raw SQL, bulk-update parent_id, or duplicate a subtree without saveAsRoot/appendToNode. Symptoms: subtrees vanish from descendants() results, and permissions "resolve to nothing" (lockout) or, worse, to a sibling's grants. Use the package's node API exclusively, and add a php artisan nestedset:check to CI on a seeded fixture.
Depth limits truncate inheritance without error. If you cap the ancestor walk (or the recursive CTE) for performance, the node past the limit inherits nothing — a lockout that looks like a permissions bug. Make the limit config-driven and log a warning when a node's depth exceeds it. The same warning is your signal to review tree design.
Orphaned nodes flip between lockout and escalation. A deleted parent with cascadeOnDelete on parent_id silently deletes the subtree; without it you get children whose ancestorsAndSelf() returns a partial chain and whose effective abilities depend entirely on your default branch. Decide the default explicitly — deny-by-default — and add a nightly check for rows whose parent_id points at nothing.
Laravel Octane will poison request-scoped memoization. The $memo array in the resolver above is correct under PHP-FPM and wrong under Octane, where the singleton survives across requests and User A's cached abilities leak into User B's request. Register the resolver as scoped() in Octane-aware apps, or clear the memo in a terminating middleware. This is the single highest-severity porting bug.
spatie/laravel-permission team scoping does not compose cleanly with node-level grants. If you enable teams, the package's implicit team_id filter will fight your explicit whereIn('model_has_roles.role_id', ...) join. Either skip teams and let the folder_permissions pivot be your only scoping axis, or pin the team in a single resolver boundary. Mixing them produces grants that appear to exist in the DB but never resolve.
Eager-load ancestors or the resolver becomes a query factory. $node->ancestors() triggers a query per call. In a listing you want Folder::with('ancestors')->get() and a resolver that accepts pre-loaded ancestry, or better, the bulk grant-set approach above. Profile with ->dd() on any endpoint where the query count scales with the number of visible rows — that is the shape of this bug.
Test the inheritance rules, not the implementation. Write the semantics as a data provider — [ancestor grants] → [expected effective abilities] — covering inherit=true, inherit=false, revocation on the parent, moved subtrees, and the deny case if you support it. Run it against the resolver, not against HTTP, so you get a readable failure when the rule changes. The bundle shipped no such suite for you; this is the work that makes the pattern safe.
The extension point you actually have is your own resolver interface. Because you cannot patch the bundle and upstream is EOL, keep PermissionResolver behind a contract with one method that returns a Collection of abilities. When you later need deny-overrides, per-user exceptions, or a cached implementation, you swap the binding and no policy changes. That is the "reusable service for access checks" the description promised — you just have to be the one who builds it.
How can I help you explore Laravel packages today?