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

Acl Bundle Laravel Package

alchemy/acl-bundle

Alchemy ACL bundle adds configurable access control entries to Symfony entities, with REST endpoints to list, create, update, and remove permissions per user, group, or object. Supports Redis-cached access tokens and flexible query filters.

View on GitHub
Deep Wiki
Context7

Getting Started

First, the honest constraint: alchemy/acl-bundle is a Symfony bundle. composer require alchemy/acl-bundle into a Laravel app does not give you a working ACL system—there is no ServiceProvider, no config/alchemy_acl.php, and the bundle's routes are registered through Symfony's kernel/routing loader. Laravel developers therefore have exactly three viable paths:

  1. Run it as a sidecar service — a small Symfony 7/8 app exposing /permissions/*, called from Laravel over HTTP. Best fidelity, extra deployable.
  2. Port the ACE data model into Eloquent (recommended for most teams) — the schema is trivial and the semantics are the valuable part.
  3. Use it as a reference implementation while building Laravel-native Gate/Policy extensions.

If you take path 1, the minimal smoke test is:

# config/packages/alchemy_acl.yaml
alchemy_acl:
  objects:
    publication: App\Entity\Publication
    asset: App\Entity\Asset
# config/services.yaml — required, bundle expects this alias
services:
    Alchemy\AclBundle\Repository\UserRepositoryInterface: '@App\Repository\UserRepository'
# config/packages/cache.yaml — pool name is hard-coded, see Gotchas
framework:
    cache:
        default_redis_provider: redis://redis
        pools:
            accessToken.cache:
                adapter: cache.adapter.redis

Then exercise the API before writing any integration code:

# grant user u-42 edit+ on publication pub-42
curl -X PUT {HOST}/permissions/ace \
  -H 'Content-Type: application/json' \
  -d '{"userType":"user","userId":"u-42","objectType":"publication","objectId":"pub-42","mask":7}'

# read it back, and confirm the null-scope variants
curl '{HOST}/permissions/aces?objectType=publication&objectId=pub-42'
curl '{HOST}/permissions/aces?userType=group&userId=g-42'

Where to look first: the four-field identity of an ACE — (userType, userId, objectType, objectId) — plus mask. Everything else is CRUD around that tuple. Read those definitions before the endpoints; the endpoint list is uninteresting once the tuple clicks.

First use case to ship: one scoped, high-value check. Replace if ($user->isAdmin()) on a single entity (e.g. Publication) with an ACE lookup. Do not start with global (objectId: null) rules—they are the easiest to get wrong and the hardest to audit.

Implementation Patterns

The tuple is your primary key. (userType, userId, objectType, objectId) uniquely identifies an ACE; PUT upserts on that key. So "grant", "revoke", "change" are all the same idempotent PUT with a different mask, and DELETE takes the same body minus mask. In Eloquent terms this is an updateOrCreate on a composite unique index—design your table that way from day one, because without the unique constraint you will silently accumulate duplicate ACEs that a ->first() will mask.

Nulls are wildcards, not errors. Two independent null axes:

userId objectId Meaning
u-42 pub-42 user 42 on that one publication
g-42 pub-42 everyone in group 42 on that publication
null pub-42 everybody on that publication (public object)
u-42 null user 42 on all publications (type-wide grant)
null null global grant — treat as a superuser/backdoor and gate it accordingly

Your evaluation query must fan out across all matching rows and OR the masks. The pattern is a single query per (objectType, objectId, userId) returning the candidate rows, then a bitwise reduce:

$mask = $aces
    ->filter(fn ($a) => ($a->user_type === 'user' && $a->user_id === $userId)
        || ($a->user_type === 'group' && in_array($a->user_id, $groupIds, true))
        || $a->user_id === null)
    ->reduce(fn ($carry, $a) => $carry | $a->mask, 0);

return ($mask & AclMask::EDIT) === AclMask::EDIT;

Note the OR, not the max, and note that userType: group requires you to resolve the user's groups before the query—the bundle does not expand group membership for you. Pass group IDs in from Laravel.

Bridge masks to Laravel Gate. The bundle speaks numeric bitmasks; Laravel speaks ability strings. A thin translator keeps both worlds sane and is the single highest-leverage piece of glue you will write:

final class AclMask
{
    public const VIEW   = 1;
    public const CREATE = 2;
    public const EDIT   = 4;
    public const DELETE = 8;

    public static function toAbility(int $mask, string $ability): bool
    {
        return ($mask & constant(self::class.'::'.strtoupper($ability))) !== 0;
    }
}

Register a single Gate::before or a custom Gate::define('edit', ...) that resolves the ACE mask for the model instance, so controllers keep using idiomatic $this->authorize('edit', $publication). Keep the ACE lookup inside a dedicated AclService — never inline the bitmask math in a controller.

Cache the resolved mask, not the ACE rows. The bundle's Redis accessToken.cache is exactly this idea. In Laravel, key on objectType:objectId:userType:userId with a short TTL and bust the key on every PUT/DELETE. Do not cache raw ACE lists unless you also version the cache on writes; partial busting here produces the classic "revoked an hour ago, still has access" bug report.

HTTP-client integration (sidecar path). Wrap the three endpoints behind a typed client so callers never see raw JSON. Handle the 404/4xx ambiguity explicitly—a missing ACE and an empty result set should both map to mask = 0, not to an exception:

class AclClient
{
    public function mask(string $objectType, string $objectId, string $userType, string $userId): int
    {
        $rows = Http::baseUrl(config('acl.base_url'))
            ->timeout(2)
            ->retry(2, 100)
            ->get('/permissions/aces', compact('objectType', 'objectId', 'userType', 'userId'))
            ->throw()
            ->json();

        return collect($rows)->reduce(fn ($c, $r) => $c | $r['mask'], 0);
    }
}

Keep the timeout tight and fail closed (mask = 0) — a permissions service that times out must not grant access.

Metadata for audit and expiry (added in the 1.1/1.2 line, alongside the event hook) is where the bundle earns its keep beyond a join table. Store granted_by, reason, expires_at next to the mask, and enforce expires_at in your evaluation layer, not only in a cron. Pair it with a model observer that writes an audit row on every ACE mutation—that single observer covers GDPR "who accessed what" and SOC2 "why was access granted" with no extra infrastructure.

Admin surface. There is no UI. The fastest path is a Laravel resource controller that proxies a paginated GET /permissions/aces into a table, plus a form that issues PUT/DELETE. Budget a day, not a week.

Gotchas and Tips

The Redis pool name is literal. accessToken.cache is auto-wired by name. Rename it to acl_cache and you get silent cache misses or a container error, with no hint that the name was the problem. Same class of problem with the UserRepositoryInterface alias—omit it and the bundle fails at container build with an interface-not-instantiable error rather than at request time.

userId: null means "everybody", which is a privilege-escalation footgun. If your admin form allows an empty user selection, you have just granted public access to the object. Validate on the Laravel side: null userId should require an explicit "Public" choice, never be the default of an empty field. Same for null objectId—a type-wide grant applied by accident is a whole-namespace permission change.

Routes ship unguarded. The bundle registers /permissions/* on the Symfony app; if you expose that app publicly, the ACEs are editable by anyone who can reach the port. Put the service on a private network or behind a shared-secret middleware. In a Laravel proxy, put the controller group behind auth:sanctum plus a policy check—being able to call the ACL API is itself an ACL.

String IDs everywhere. objectId in the API is a string ("pub-42"), even when your Eloquent key is an auto-increment integer. Cast on both sides or you get a cache key of :42 vs :"42" and a cache hit rate of zero.

objectType must exactly match the YAML key (publication, asset), not the entity class. Typos produce a valid-looking API call that silently writes an ACE nobody ever queries. Constrain it with a PHP enum in your integration layer.

Mask collisions and missing semantics. The bundle gives you integers and nothing else—no enum, no validation of which bits are legal, no mask: 0 meaning. Decide the bit vocabulary yourself, document it, and enforce it in a FormRequest. mask: 0 is a valid "explicit deny" only because you decide it is; a row with mask: 0 and a row that doesn't exist are indistinguishable in a naive OR-reduce.

Allow/deny precedence is undefined. With only masks, there is no notion of a denying ACE overriding an allowing one. If you need that, encode it yourself (e.g. a high bit as a deny flag, checked before the OR-reduce) rather than assuming the bundle does it.

Debugging an unexpected allow follows a fixed order: (1) list aces?objectType=X&objectId=Y and check for a userId: null row; (2) list aces?userType=group&userId=<each group> — group membership is the usual culprit; (3) check for a objectId: null type-wide row; (4) inspect the Redis key, since a stale cached mask survives a correct DB state. Log the resolved mask and the winning ACE ID at debug level; otherwise every permission bug becomes an archaeology project.

Cache invalidation on write is your responsibility. The accessToken.cache pool is described as a fast lookup for tokens, not as an invalidation-aware store. If you proxy or port, treat every PUT/DELETE as a cache-bust event, and prefer TTLs measured in seconds-to-minutes over long-lived entries.

Symfony 8 pin vs. your Laravel version. 1.5.0 targets Symfony 8; Laravel 10/11 sit on Symfony 6/7 components. For the sidecar path this is fine—separate composer.json per app. For an in-app install it is a hard conflict. In practice: sidecar or port, not "just composer require it".

Maturity and bus factor. 4 stars, 0 dependents, a single recurring contributor, and release notes consisting of framework bumps. The code is small enough to read in an afternoon—read it before you depend on it, and vendor-fork it (or port the model) so a stalled upstream does not block your own Symfony major upgrade. The Event previous change in 1.2.0 and the new Metadata support in 1.1.0 are the extension points worth understanding; everything else is thin CRUD.

What it genuinely buys you. A settled, well-factored answer to "user vs. group, object vs. type-wide, per-object mask" that has already been debugged by someone else. That tuple design is worth copying even if you never run the bundle—it is the part of this package a Laravel team should adopt with confidence, and the Symfony packaging is the part they should leave behind.

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.
ably/pubsub-server
mstfkhazaal/defender
mfonte/base62x
karnoweb/metapilot
tobya/bcsapiwrapper
swalker2/cpanel
vish4395/laravel-translation-manager
morningtrain/economic
teksite/systeminfo-laravel
atomcms/voting
run6/weather
siokas/laravelembeddirectives
adamlien/pkuuids
gilsonsouza/lara-doc-generator
manelgavalda/enrollment_mobile
unabomber/timezone
delatbabel/contacts
astritzeqiri/g-recaptcha
kevinongko/laravel-genode
developeruz/laravel-analytics