ns/security-bundle
A PHP security bundle for Laravel applications that centralizes common security configuration and protections. It helps teams add consistent safeguards, reduce boilerplate, and keep security settings easier to manage across projects, with a focus on simple setup and maintainability.
Confirm the package identity first. The repo is NobletSolutions/SecurityBundle but the package name is listed as ns/security-bundle. Check composer.json inside the repo for the real name field before running composer require; a mismatch here is the most common reason the install "fails" for a 0-star package. With no tagged releases, expect to pin a commit:
composer require ns/security-bundle:dev-master#
Where to look in the source, in this order:
DependencyInjection/Configuration.php and *Extension.php — tells you the config keys (table/entity name, connection, maybe cache).Entity/ or Model/ — the ACL row shape. You need to know whether it stores (objectType, objectId, identity, permission) as strings, ints, or a bitmask.AclManager, SecurityManager, or checker service). Read this method line by line — it is the entire security boundary.Resources/config/doctrine/*.orm.xml or migrations — this is the schema you must install.Register and configure. Small bundles often aren't in config/bundles.php automatically if they predate Flex recipes. Add new NobletSolutions\SecurityBundle\SecurityBundle() and a config/packages/security_bundle.yaml matching whatever keys step 1 revealed.
Install the schema into your migrations. Don't rely on the bundle to own the schema. Generate the table from the mapped entity, then copy the CREATE TABLE into an application migration with the indexes you want:
CREATE TABLE acl_entry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
object_type VARCHAR(255) NOT NULL,
object_id VARCHAR(64) NOT NULL,
identity VARCHAR(255) NOT NULL,
permission SMALLINT NOT NULL
);
CREATE INDEX acl_lookup ON acl_entry (object_type, object_id, identity);
First use case — owner access on create. The canonical row-level ACL workflow: after persisting an object, write an OWNER/ALL row for the creator, then read it back through the checker before rendering the object.
// after $order is persisted
$acl->grant('order', $order->getId(), $order->getOwner(), 'owner');
// before rendering
if (!$acl->isGranted('order', $order->getId(), $user, 'view')) {
throw $this->createAccessDeniedException();
}
The exact method names and permission representation will not match the above — verify against the checker service before you build anything on top of it.
Wrap the bundle in a Symfony voter. Do not call the bundle's checker directly from controllers or templates. Write one AclVoter per object type (or one generic voter keyed off the object class) that delegates to the bundle service and returns ACCESS_GRANTED/ACCESS_DENIED. This gives you isGranted('view', $order), #[IsGranted] attributes, Twig is_granted(), and a single place to add role/owner overrides later.
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
return $this->acl->isGranted(
$this->typeOf($subject), // stable object type, not get_class()
$this->idOf($subject),
$token->getUser(),
$attribute
);
}
Store a stable object type, not a FQCN. Derive it from something you control (class constant, Order::ACL_TYPE) so moving namespaces doesn't invalidate every ACL row.
Auto-grant via Doctrine lifecycle listeners. A postPersist listener that writes the owner row keeps controllers free of ACL plumbing. Pair it with preRemove to delete rows so you don't accumulate orphans.
Batch the checks for listings. The naive pattern — one checker call per row inside a foreach over an index page — is an N+1 on the ACL table. Add a batch method (loadAcls(string $type, array $ids)) or at minimum memoize per request. For a 25-row page this is the difference between 1 and 25 queries.
Request-scoped cache, explicit invalidation. Cache [type, id, identity] => permissions in a per-request array or a short-TTL pool. Invalidate on grant()/revoke(); do not invalidate globally from an admin screen and hope.
Ownership as an implicit grant. If the bundle has no concept of "owner", encode it as a permission bit and have the voter treat owner as implying view/edit. Do not write five rows per object to express one idea.
Role and group inheritance, if absent, belongs in your voter. Check $token->getRoleNames() before falling through to the DB check. Keep the ordering explicit: owner → role grant → ACL row → deny.
Scope multi-tenant data in the key or the query. If two tenants can both have object id 17, either include a tenant column in the lookup or namespace the type (tenant_7_order). This is easy to miss until a cross-tenant leak appears.
Test at the voter level, seed at the repository level. Fixtures that insert ACL rows plus a handful of voter unit tests give you far more confidence than testing controllers, and they're fast.
object_id in the WHERE clause, string-interpolated values, and a default-allow branch when no rows are found. Deny-by-default is the only acceptable fallback.$user->getUsername() but you pass a UserInterface object (or vice versa), every check silently denies — or worse, allows. Confirm how identity is normalized and make your voter pass exactly that.object_id as VARCHAR(64) plus an integer id compares fine in most DBs but fails on UUIDs with trailing whitespace and on composite keys. Cast to (string) at the boundary and be deliberate.get_class() vs proxies. Doctrine lazy proxies report generated class names. Never use get_class($entity) as the ACL type — use a constant or ClassUtils::getClass().isGranted call.Configuration.php rather than guessing at YAML keys — an unknown key is silently ignored in many small extensions.composer update can change your security boundary overnight. Pin the commit hash and consider reading the diff on every bump.json_encode of a relation.How can I help you explore Laravel packages today?