alchemy/acl-bundle
Symfony bundle providing a simple ACL API. Configure object types, alias your UserRepository, and add Redis cache for access tokens. Exposes endpoints to list, upsert, and delete ACEs by user/group, object type/id, with permission masks and wildcards.
Install the Bundle (via Composer):
composer require alchemy/acl-bundle
Note: Requires Symfony components. Use composer require symfony/options-resolver symfony/attribute if missing.
Configure Objects (in config/acl.php):
return [
'objects' => [
'publication' => App\Models\Publication::class,
'asset' => App\Models\Asset::class,
],
];
Alias User Repository (in AppServiceProvider):
public function register()
{
$this->app->bind(
Alchemy\AclBundle\Repository\UserRepositoryInterface::class,
App\Repositories\UserRepository::class
);
}
Set Up Redis Cache (in .env):
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
First Use Case: Check Permissions Use the API endpoint via Laravel’s HTTP client:
$response = Http::get('http://symfony-app/permissions/aces', [
'objectType' => 'publication',
'objectId' => 'pub-123',
]);
Define Permissions via API Grant a user edit access to a publication:
Http::put('http://symfony-app/permissions/ace', [
'userType' => 'user',
'userId' => 'user-42',
'objectType' => 'publication',
'objectId' => 'pub-123',
'mask' => 2, // Edit permission (binary 010)
'metadata' => ['reason' => 'editorial_override']
]);
Check Permissions in Laravel Create a service to proxy API calls:
class AclService {
public function hasPermission(string $userId, string $objectType, string $objectId, int $mask): bool
{
$aces = Http::get('http://symfony-app/permissions/aces', [
'userType' => 'user',
'userId' => $userId,
'objectType' => $objectType,
'objectId' => $objectId,
])->json();
return collect($aces)->contains(fn ($ace) => $ace['mask'] & $mask);
}
}
Integrate with Eloquent Models Add a trait to models for permission checks:
trait HasAclPermissions {
public function userCan($userId, $permissionMask)
{
return app(AclService::class)->hasPermission(
$userId,
$this->getAclObjectType(),
$this->id,
$permissionMask
);
}
}
Attach Metadata to Permissions
Http::put('http://symfony-app/permissions/ace', [
'userType' => 'user',
'userId' => 'user-42',
'objectType' => 'publication',
'objectId' => 'pub-123',
'mask' => 4, // Publish permission (binary 100)
'metadata' => [
'expires_at' => now()->addDays(7)->toDateTimeString(),
'department' => 'editorial'
]
]);
Validate Metadata in Laravel Check if a permission is expired:
$ace = Http::get('http://symfony-app/permissions/aces', [
'userType' => 'user',
'userId' => 'user-42',
'objectType' => 'publication',
'objectId' => 'pub-123',
])->json()[0];
if (isset($ace['metadata']['expires_at']) &&
strtotime($ace['metadata']['expires_at']) < time()) {
throw new \Exception('Permission expired');
}
Grant Permissions to Groups
Http::put('http://symfony-app/permissions/ace', [
'userType' => 'group',
'userId' => 'group-editors',
'objectType' => 'publication',
'objectId' => null, // Applies to all publications
'mask' => 2, // Edit permission
]);
Check Group Membership in Laravel
Extend the AclService to resolve group memberships:
public function userInGroup($userId, $groupId): bool
{
// Implement logic to check if user belongs to group
return true;
}
public function hasGroupPermission($userId, $groupId, $objectType, $objectId, $mask): bool
{
$aces = Http::get('http://symfony-app/permissions/aces', [
'userType' => 'group',
'userId' => $groupId,
'objectType' => $objectType,
'objectId' => $objectId,
])->json();
return $this->userInGroup($userId, $groupId) &&
collect($aces)->contains(fn ($ace) => $ace['mask'] & $mask);
}
Symfony Component Conflicts
Attribute or OptionsResolver may conflict with Laravel’s autoloading."extra": {
"laravel": {
"dont-discover": ["symfony/*"]
}
}
Mask vs. Named Permissions
Gate uses named methods (can('edit')), while this bundle uses numeric masks (mask: 2).class PermissionMask {
public const READ = 1;
public const EDIT = 2;
public const PUBLISH = 4;
public static function toMask(string $permission): int
{
return match ($permission) {
'read' => self::READ,
'edit' => self::EDIT,
'publish' => self => PUBLISH,
default => 0,
};
}
}
Metadata Serialization
$metadata = json_decode($ace['metadata'], true);
Redis Cache Configuration
accessToken.cache.Cache::extend('accessToken', function () {
return Cache::repository(new RedisStore(config('cache.redis')));
});
Null ObjectId/Null UserId
objectId: null or userId: null applies permissions globally, which may be unintended.if ($objectId === null) {
throw new \InvalidArgumentException('Global permissions not allowed');
}
Inspect ACEs
Use the /permissions/aces endpoint to debug:
curl http://symfony-app/permissions/aces?objectType=publication&objectId=pub-123
Log API Responses
Add logging in your AclService:
\Log::debug('ACL API Response', ['response' => $response->json()]);
Test Mask Calculations Verify bitwise operations:
// Check if user has READ (1) or EDIT (2) permissions
$hasPermission = ($ace['mask'] & (PermissionMask::READ | PermissionMask::EDIT)) > 0;
Handle API Failures Retry transient failures:
try {
$response = Http::retry(3, 100)->get('...');
} catch (\Throwable $e) {
\Log::error('ACL API failed', ['error' => $e->getMessage()]);
return false;
}
Custom Metadata Validation Extend the bundle’s metadata handling by adding Laravel validation rules:
use Illuminate\Validation\Rule;
$validator = Validator::make($metadata, [
'expires_at' => ['required', 'date', Rule::unique('permissions_metadata')->where(fn ($query) => $query->where('object_id', $objectId))],
]);
**Event Listeners for
How can I help you explore Laravel packages today?