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

Secure Controller Bundle Laravel Package

leaseweb/secure-controller-bundle

A Symfony bundle that adds declarative security to controllers, letting you enforce authentication and authorization rules with attributes or annotations instead of repetitive boilerplate checks in every action.

View on GitHub
Deep Wiki
Context7

Getting Started

This bundle does one thing: it reads a @Secure annotation off a controller action and throws AccessDeniedException if the current token doesn't hold the required role. That's it — no voters, no ACLs, no expressions.

1. Install and register. There is no Flex recipe, so it's manual:

composer require leaseweb/secure-controller-bundle
// app/AppKernel.php (Symfony 2.x/3.x)
public function registerBundles()
{
    $bundles = [
        // ...
        new Lsw\SecureControllerBundle\LswSecureControllerBundle(),
    ];
}

You need a working firewall (the listener pulls the security context/token storage), and doctrine/annotations must be installed — it is a transitive dependency in most Symfony 2 apps, but check composer show doctrine/annotations before blaming the bundle.

2. Where to look first. Before writing a single annotation, read three files in vendor/leaseweb/secure-controller-bundle:

  • Annotation/Secure.php — tells you the exact property name (value vs roles) and whether it accepts a single string, an array, or both.
  • EventListener/ControllerListener.php (or similarly named) — tells you which event it hooks (kernel.controller), what exception it throws, and whether it checks isMasterRequest().
  • composer.json — tells you which Symfony versions you can actually run this on. The last tagged release is from March 2016, so expect ~2.3-era constraints. If you're on Symfony 4+, this is a fork-and-port job, not an install.

3. First use case. The canonical scenario is a single admin action in an otherwise public controller:

namespace Acme\DemoBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Lsw\SecureControllerBundle\Annotation\Secure;

class DashboardController extends Controller
{
    /**
     * @Secure("ROLE_ADMIN")
     */
    public function adminAction()
    {
        // only reachable with ROLE_ADMIN
    }
}

The use statement is mandatory — Doctrine's annotation reader resolves the short name through imports. Skip it and you get a parser error, not a silent bypass (see Gotchas).

Implementation Patterns

Protecting every write action in a CRUD controller. Annotate each mutating action rather than the class — the bundle is action-scoped, and putting @Secure above the class body is a no-op:

/**
 * @Secure("ROLE_ADMIN")
 */
public function deleteAction($id) { /* ... */ }

/**
 * @Secure("ROLE_EDITOR")
 */
public function editAction($id) { /* ... */ }

Layer it under access_control, never instead of it. The bundle is a convenient per-action guard for exceptions to a URL-prefix rule. Put the coarse rule in security.yml:

access_control:
    - { path: ^/admin, roles: ROLE_ADMIN }

…and use @Secure for the handful of actions inside a public controller that need elevation. Two independent checks means a mis-typed annotation doesn't open a hole.

Prefer it over repeated if (!$this->get('security.context')->isGranted(...)) boilerplate. The value of the annotation is that the check happens before the action body runs, in the kernel.controller listener, so no side effects occur on a denied request and you get a clean 403 through Symfony's ExceptionListener. Replacing five manual isGranted calls with five annotations also makes the security surface greppable:

grep -rn "@Secure" src/

Combine with parameter conversion carefully. Because the listener fires on kernel.controller — before ParamConverter and argument resolution — a denied request never hits the database for a 404. That's a feature (no information leak about whether ID 42 exists), but it also means a user with a valid role but no ownership of the entity still gets through. Ownership/permission checks belong in a voter called from the action body, or in a custom listener of your own at a lower priority.

Testing. The listener reacts to a dispatched event, so functional tests behave exactly as production if you log in properly:

$client = static::createClient();
$client->request('GET', '/demo/admin');
$this->assertSame(403, $client->getResponse()->getStatusCode());

$client->getContainer()->get('security.context')->setToken(
    new UsernamePasswordToken('admin', null, 'main', ['ROLE_ADMIN'])
);
$client->request('GET', '/demo/admin');
$this->assertSame(200, $client->getResponse()->getStatusCode());

Assert the 403 explicitly. A test that only checks the happy path will not catch an annotation that silently stopped being read after a refactor.

Subrequests. If the annotated action is rendered as a fragment (ESI, render() in Twig), decide whether the check should apply. Most implementations don't filter on isMasterRequest(), so a fragment rendered in a page the user can see will throw and, depending on your error handling, produce a 500 or an empty box.

Gotchas and Tips

This is an unmaintained 2016 package with zero dependents. Treat it as a ~200-line piece of logic you're adopting, not a library you're depending on. If you're on Symfony 3+, open the listener first: if it uses security.context, it will not run on Symfony 3.0+ without patching to security.token_storage + security.authorization_checker. For a modern codebase, porting the listener into your own kernel.controller subscriber is usually cheaper than fighting the constraint.

Missing use statement = hard failure, not silent bypass. Doctrine throws AnnotationException: [Semantical Error] The annotation "@Secure" ... was never imported. That's the good outcome. The bad outcome is a controller that used to be annotated and now isn't — so if security matters, add the grep above to CI, or a functional test per protected route.

Method inheritance drops the annotation. ReflectionMethod on an overriding child method will not see the parent's docblock. Subclass a secured controller, override editAction, and the annotation is gone. Same for traits if the docblock isn't on the consuming method. Re-annotate every override.

Anonymous users and missing firewalls. With no token, isGranted() returns false and you get a 403 — correct. But if the request never passed through a firewall (e.g. a route outside security.firewalls patterns, or a CLI/console invocation that dispatches kernel.controller), the context may be unavailable and you'll get a container/service exception instead of a 403. Check debug:event-dispatcher kernel.controller to confirm the listener is wired at all.

403 vs 500. The AccessDeniedException only becomes a 403 if the firewall's exception listener is in the request path. Actions reached outside a firewall context surface as 500s. When a user reports a 500 on an admin page, check the annotation before you check the action code.

Annotations are read at runtime. Every request to an annotated action parses doctrine annotations unless the reader is cached. On a hot path this is measurable. If it matters, cache the reader (doctrine/annotations CachedReader with a FilesystemCache) or move the check into a static security.yml rule.

Multiple roles: verify the semantics. Whether @Secure({"ROLE_A", "ROLE_B"}) means "any of" or "all of" is defined by the listener, not by intuition. Read Annotation/Secure.php and the check itself. Getting this backwards in the permissive direction is a real vulnerability — the most common intent is OR, and "all of" is the more common implementation.

Role hierarchy works, string matching doesn't. isGranted() goes through the AccessDecisionManager, so ROLE_SUPER_ADMIN inheriting ROLE_ADMIN in security.yml is honoured. But @Secure("ROLE_ADMIN") is an exact string at the annotation level — no wildcards, no expressions, no is_granted()-style syntax. For anything conditional, use a voter and call it from the action.

Extension point. Don't edit vendor. If you need ownership checks or logging of denials, register your own kernel.controller listener at a lower priority than the bundle's, or decorate the bundle's listener service (check Resources/config/services.xml for the service id). A decorator is the cleanest place to add a "this action also requires the entity owner" rule without forking.

Debugging checklist when an annotation "doesn't work": (1) use statement present; (2) annotation on the concrete method, not a parent; (3) method is public — private/protected actions are never dispatched as controllers; (4) listener registered (debug:event-dispatcher); (5) cache:clear in dev after editing annotations, since docblock changes don't invalidate all cache layers; (6) request actually goes through a firewall.

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.
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings