The @Security
annotation, which originated in the Sensio extra bundle, goes a long way. The official upgrade docs have a few misleading pointers, that force you to use unnecessary verbose language.
Fortunately, few hidden levels make code much less verbose and more readable.
This post sums up upgrading the Sensio @Security
annotation to Symfony #[IsGranted()]
attribute in one place.
Note: If you already use the native Symfony attributes, scroll down to find syntax tips.
We have a Symfony project that uses sensio/framework-extra-bundle
to define security rules above the controller class or method:
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
/**
* @Security("has_role('ROLE_USER')")
* @Security("is_granted('read') and is_granted('write')")
*/
class SomeController
{
}
We want to:
sensio/framework-extra-bundle
package#[IsGranted()]
attributeTo give you a better context, here is an evolution of the @Security
annotation across 2 packages:
sensio/framework-extra-bundle
@Security
annotation@IsGranted
annotation#[Security]
and '#[IsGranted]
symfony/security-http
#[IsGranted]
attribute to allow migration from deprecated Sensio packageWhat is the best way to start? Should we go directly from Sensio annotations to Symfony attributes? Is the syntax the same? But what about the @Security
annotation? It doesn't have any native Symfony equivalent.
There are many paths to start this migration. We're lazy devs working on a huge project, so we'll pick the smallest step possible.
We'll start with the upgrade of annotations to attribute for one simple reason: **abstract syntax tree (technology used by Rector) works better with native PHP attributes than with lousy docblocks. Once we have native PHP 8.0 attributes, we can start using Rector to help us with the rest of the upgrade.
First, we upgrade at least to sensio/framework-extra-bundle
6.0. Then we use Rector to handle the upgrade. We update the config:
# rector.php
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withAttributesSets(sensiolabs: true)
And run Rector:
vendor/bin/rector
That's it, now we have native PHP attributes. Yay!
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
#[Security("has_role('ROLE_USER')")]
#[Security("is_granted('read') and is_granted('write')"]
class SomeController
{
}
Ideally, we could switch directly to the symfony/security-http
package, but there are 2 small problems:
#[Security]
attribute in Symfony core#[Security]
attribute:Today, we're looking into Symfony @Security annotations.
— Tomas Votruba (@VotrubaT) April 7, 2025
Framework bundle extra allowed multiple annotations:
/**
* @Security('...')
* @Security('...')
* @Security('...')
*/
class SomeController {}
But only single attribute is allowed:
#[Security('...')]
class…
Someone forgot to allow the attribute to be used multiple times. That means the code above will throw a native PHP error because we're using two #[Security]
attributes in the same place.
The Sensio repository is archived, so we can't fix it there. No worries, we just patch /vendor
file:
-#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
+#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class Security extends ConfigurationAnnotation
#[Security]
to Sensio #[IsGranted]
Before we switch to Symfony core, we want to get into the best position there is.
There are 3 steps we can achieve that: we change is_granted()
to #[IsGranted()]
:
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
+use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
-#[Security("is_granted('listen')"]
+#[IsGranted('listen')]
class SomeController
{
}
Fewer strings, more native code, better code resilience. Now we can instantly see, that "listen" is our custom value.
How about the has_role
? Do we have to keep this string or can we improve it somehow?
Actually, the is_granted()
deprecated and replaced has_role()
in Symfony 4.2.
We can replace the role check directly with the #[IsGranted()]
attribute:
-use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
+use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
-#[Security("has_role('ROLE_USER')"]
+#[IsGranted('ROLE_USER')]
class SomeController
{
}
Nice and clean!
When I was debugging Sensio to fix the attribute, I discovered the attributes stacked on each other. It means all of the attributes must pass, or the access will be denied.
Saying that we can split "and" expressions into standalone attributes:
-use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
+use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
-#[Security("is_granted('read') and is_granted('write')"]
+#[IsGranted('read')]
+#[IsGranted('write')]
class SomeController
{
}
Voilá!
You can do changes manually, or be lazy like me and let Rector handle steps 3, 4, and 5 for you:
# rector.php
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPreparedSets(symfonyCodeQuality: true)
Now we can build on the steps we've made before. We got from magic verbose definitions in docblock, to neat and tight single-word attributes.
The "read" is not some technical keyword from PHP. It's a made-up string we use here and most likely somewhere else. It's an enum we use over and over.
So let's extract it:
class Permission
{
public string const READ = 'read';
}
And replace it here and in the other place - voter, security PHP config, etc.:
-#[IsGranted('read')]
+#[IsGranted(Permission::READ)]
class SomeController
{
}
Now we can easily jump back and forth between places where the Permission::READ
is used thanks to IDE.
To align our codebase to this rule in the present and the future, we add a simple custom PHPStan rule:
use PhpParser\Node;
use PhpParser\Node\Attribute;
use PhpParser\Node\Scalar\String_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
final class RequireIsGrantedEnumRule implements Rule
{
public function getNodeType(): string
{
return Attribute::class;
}
/**
* @param Attribute $node
*/
public function processNode(Node $node, Scope $scope): array
{
if ($node->name->toString() !== IsGranted::class) {
return [];
}
$isGrantedExpr = $node->args[0]->value;
if (! $isGrantedExpr instanceof String_) {
return [];
}
return [
RuleErrorBuilder::message(sprintf(
'Instead of "%s" string, use enum constant for #[IsGranted]',
$isGrantedExpr->value
))
->identifier('symfony.requireIsGrantedEnum')
->build()
];
}
}
So we get a nice warning in our CI:
--------------------------------------------------------------------------
src/Controller/SomeController.php:8
--------------------------------------------------------------------------
- '#Instead of "read" string, use enum constant for \#\[IsGranted\]#'
🪪 symfony.requireIsGrantedEnum
--------------------------------------------------------------------------
Now we have our Sensio attributes in the best shape possible. We use strings only in the place where really necessary. We use an enum constant list for easier jumping back and forth and we have a custom PHPStan rule to enforce it.
First, we make use we use symfony/security-http
6.2 where the #[IsGranted()]
attribute is available.
Then we enable the SecurityAttributeToIsGrantedAttributeRector
rule in our rector.php
config:
# rector.php
use Rector\Config\RectorConfig;
use Rector\Symfony\Symfony62\Rector\Class_\SecurityAttributeToIsGrantedAttributeRector
return RectorConfig::configure()
->withRules([SecurityAttributeToIsGrantedAttributeRector::class]);
And run Rector:
vendor/bin/rector
That's it, now we have native Symfony PHP attributes and also,
the best available syntax possible. Yay!
Happy coding!