Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Acltree Bundle Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. The parent/children contract the bundle expects your entity to implement — this is the whole point: the ACL subject is a tree node.
  2. The service in 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.
  3. The README's example entity, to see how little structure is actually required (self-referencing relation + an ACL entry per node).

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.

Implementation Patterns

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.

  • Nested set (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.
  • Adjacency list + recursive CTE (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:

  • Nearest-ancestor-wins — first node walking up with any grant decides. Simple, predictable, matches filesystem intuition.
  • Union of inherited grants — all ancestor grants accumulate. Easier UX, but revocation becomes confusing because a grant on a grandparent still applies after you remove one on the parent.

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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings