All the New PHP Attributes at a Glance
PHP 8 attributes have largely replaced YAML and XML in Symfony configuration. Symfony 7 brings dozens of PHP attributes for routing, dependency injection, events, caching, security and request mapping, all with semantics identical to the previous configuration formats, but considerably more compact and placed directly on the code.
Table of Contents
- 1. Why PHP Attributes Replace YAML in Symfony
- 2. Routing Attributes: Route, RoutePrefix and Requirements
- 3. Dependency Injection Attributes: Autowire, AutowireIterator, AsAlias
- 4. Event Attributes: AsEventListener and AsMessageHandler
- 5. Request Attributes: MapQueryString, MapRequestPayload, MapUploadedFile
- 6. Cache Attributes: Cache, IsGranted and HttpCache
- 7. Security Attributes: IsGranted and Security
- 8. Serializer Attributes: Groups, SerializedName, Ignore
- 9. Attributes vs. YAML: When to Use What?
- 10. Summary
- 11. FAQ
1. Why PHP Attributes Replace YAML in Symfony
Native PHP 8 attributes replace three different configuration formats in Symfony 7: YAML, XML and annotations (Doctrine annotations via @Route, @IsGranted). The decisive advantage over YAML is that PHP attributes sit directly on the code, on the controller, on the service class, on the event listener. There is no separate configuration file that has to be kept in sync with the code. When a class is deleted, its configuration disappears automatically along with it. That eliminates a common source of errors in larger Symfony projects.
Compared to the old Doctrine annotations (use Doctrine\Common\Annotations), native PHP attributes have another advantage: they are parsed natively by the PHP interpreter, no extra Doctrine package, no custom parser, no annotation classes with constructors. The IDE understands them as normal PHP syntax, can check types and offer autocompletion. PHP attributes in Symfony 7 are not syntax magic, they are genuine first-class PHP citizens. Symfony 7 reads them via ReflectionAttribute, fast, maintainable and without an external dependency.
2. Routing Attributes: Route, RoutePrefix and Requirements
The #[Route] attribute is the best known PHP attribute in Symfony. It defines path, HTTP methods, name, requirements, defaults and host directly on the controller method. At class level, #[Route] acts as a prefix for all method routes. Requirements are passed as an array of regex patterns: requirements: ['id' => '\d+'] restricts the path parameter to digits. The #[Route] attribute can be placed multiple times on a single method to register the same method under several paths, useful for versioned APIs.
The name attribute inside #[Route] sets the route name explicitly. If it is missing, Symfony generates a name from the controller class name and the method. In larger projects an explicit name is recommended, it makes generate() calls in the code unambiguous and prevents name collisions during renames. The locale attribute enables localized routes: #[Route(path: ['de' => '/produkte/{id}', 'en' => '/products/{id}'], name: 'product_show')] registers both path variants under one name and Symfony picks one based on the current locale. That is a powerful PHP attribute feature for international Symfony projects.
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
// Class-level Route acts as prefix for all methods
#[Route('/api/v1/products', name: 'product_')]
final class ProductController extends AbstractController
{
// GET /api/v1/products, name: product_list
#[Route('', name: 'list', methods: ['GET'])]
public function list(): JsonResponse
{
return $this->json([]);
}
// GET /api/v1/products/{id}, id must be numeric
#[Route('/{id}', name: 'show', methods: ['GET'], requirements: ['id' => '\d+'])]
public function show(int $id): JsonResponse
{
return $this->json(['id' => $id]);
}
// POST /api/v1/products, also accessible via PUT for upsert
#[Route('', name: 'create', methods: ['POST'])]
#[Route('/{id}', name: 'upsert', methods: ['PUT'], requirements: ['id' => '\d+'])]
public function create(?int $id = null): JsonResponse
{
return $this->json([], Response::HTTP_CREATED);
}
// Localized route, Symfony picks based on current locale
#[Route(path: ['de' => '/suche', 'en' => '/search'], name: 'search')]
public function search(): JsonResponse
{
return $this->json([]);
}
}
3. Dependency Injection Attributes: Autowire, AutowireIterator, AsAlias
The #[Autowire] attribute from Symfony\Component\DependencyInjection\Attribute is the most important PHP attribute for dependency injection in Symfony 7. It allows a service, a parameter or an environment variable value to be wired in explicitly, directly on the constructor argument, without a services.yaml entry. #[Autowire(service: 'logger')] injects a specific service by ID. #[Autowire('%app.api_key%')] injects a container parameter. #[Autowire(env: 'DATABASE_URL')] injects an environment variable. All of this works without touching services.yaml.
#[AutowireIterator] collects all services tagged with a given tag into an iterable collection and injects them as a bundle. The classic example: a handler registry that gathers all services tagged app.handler and sorts them by type or priority. #[AsTaggedItem] on the handler classes sets the tag and optional metadata such as priority. #[AsAlias] registers a service under an alternative interface name, useful for publishing a concrete class under an interface alias in the container. #[AsDecorator] decorates another service without changing services.yaml.
<?php
declare(strict_types=1);
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
// Register this service as the implementation of NotifierInterface
#[AsAlias(id: NotifierInterface::class)]
final class EmailNotifier implements NotifierInterface
{
public function __construct(
// Inject specific logger channel, no services.yaml entry needed
#[Autowire(service: 'monolog.logger.mailer')]
private readonly LoggerInterface $logger,
// Inject container parameter directly
#[Autowire('%mailer.from_address%')]
private readonly string $fromAddress,
// Inject environment variable with type casting
#[Autowire(env: 'int:MAIL_RETRY_COUNT')]
private readonly int $retryCount,
) {}
public function notify(string $message): void
{
$this->logger->info('Sending notification', ['message' => $message]);
}
}
// Collect all payment processors tagged with app.payment_processor
final class PaymentRegistry
{
/** @var iterable<PaymentProcessorInterface> */
private readonly iterable $processors;
public function __construct(
#[AutowireIterator('app.payment_processor', defaultIndexMethod: 'getIdentifier')]
iterable $processors,
) {
$this->processors = $processors;
}
}
// Tag a payment processor with priority, higher priority runs first
#[AsTaggedItem(tag: 'app.payment_processor', priority: 10)]
final class StripePaymentProcessor implements PaymentProcessorInterface
{
public static function getIdentifier(): string { return 'stripe'; }
}
4. Event Attributes: AsEventListener and AsMessageHandler
The PHP attribute #[AsEventListener] registers a class or a method as an event listener without any services.yaml configuration. At class level, the class must have an __invoke method that accepts the event type as a parameter, Symfony infers the event type from the type hint. At method level, several listeners can be defined in one class: #[AsEventListener(event: ProductCreatedEvent::class, priority: 10)] registers the annotated method for exactly that event at the given priority. Multiple #[AsEventListener] attributes on the same method register it for several events.
#[AsMessageHandler] registers a class as a Messenger handler without manual YAML configuration. With PHP 8.4 and constructor property promotion, a separate __invoke method is no longer required, the attribute can also sit on a named method. #[AsMessageHandler(bus: 'messenger.bus.commands')] restricts the handler to a specific bus. For saga-like handlers that receive several messages, the attribute is placed multiple times on different methods of one class. These PHP attributes for event and message handling eliminate a large part of the services.yaml configuration in event-driven Symfony projects.
5. Request Attributes: MapQueryString, MapRequestPayload, MapUploadedFile
The request mapping PHP attributes in Symfony 7 cover every common input source. #[MapQueryString] maps query parameters, #[MapRequestPayload] the request body, and #[MapUploadedFile] file uploads directly into controller arguments. #[MapUploadedFile] accepts an UploadedFile object or an array of them and can be validated against file type and size using Symfony constraints, all without manual access to $request->files. The attribute #[MapRequestPayload(type: MyDto::class)] allows the target type to be specified explicitly when PHP typing alone is not enough.
The attribute #[ValueResolver] is the more generic counterpart for custom argument resolvers. Anyone who needs complex mapping logic, for example a resolver that derives a user value object from a JWT token, implements a ValueResolverInterface and annotates the argument with #[ValueResolver(MyResolver::class)]. This replaces the old ArgumentValueResolverInterface from Symfony 5/6 with a more explicit, attribute-based pattern. For standard REST APIs, #[MapQueryString] and #[MapRequestPayload] are entirely sufficient in the vast majority of cases.
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Dto\CreateProductInput;
use App\Dto\ProductListQuery;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\HttpKernel\Attribute\MapUploadedFile;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Constraints as Assert;
#[Route('/api/products', name: 'product_')]
final class ProductController extends AbstractController
{
// Combine MapQueryString + MapRequestPayload in one method
#[Route('', name: 'list', methods: ['GET'])]
public function list(#[MapQueryString] ProductListQuery $query): JsonResponse
{
return $this->json(['page' => $query->page, 'limit' => $query->limit]);
}
// MapRequestPayload auto-detects JSON / form-data from Content-Type
#[Route('', name: 'create', methods: ['POST'])]
public function create(#[MapRequestPayload] CreateProductInput $input): JsonResponse
{
return $this->json(['name' => $input->name], Response::HTTP_CREATED);
}
// MapUploadedFile, validate file type and size via constraints
#[Route('/{id}/image', name: 'upload_image', methods: ['POST'])]
public function uploadImage(
int $id,
#[MapUploadedFile([
new Assert\NotNull(),
new Assert\File(maxSize: '5M', mimeTypes: ['image/jpeg', 'image/png', 'image/webp']),
])]
UploadedFile $image,
): JsonResponse {
// $image is validated, process the upload here
return $this->json(['uploaded' => $image->getClientOriginalName()]);
}
}
6. Cache Attributes: Cache, IsGranted and HttpCache
The #[Cache] attribute from the successor of SensioFrameworkExtraBundle controls HTTP caching headers directly on the controller method. #[Cache(maxage: 3600, public: true)] sets Cache-Control: public, max-age=3600 without a manual call to $response->setMaxAge(3600). The smaxage attribute sets the shared max-age for reverse proxies such as Varnish or Symfony HttpCache. lastModified and etag enable conditional requests, the browser sends If-Modified-Since or If-None-Match, Symfony checks and returns 304 without executing the controller if the resource is unchanged.
In Symfony 7, the symfony/http-kernel package also offers the #[WithHttpCache] attribute, which works without an extra bundle. For granular cache control, for example different TTLs depending on the user role, a manual call on the response object stays more flexible. The PHP attribute is ideal for public, static resources such as product listings or categories, where a single cache header fits every response of the method. For dynamic cache keys or Vary headers that depend on the request context, the manual approach in the controller is preferable.
7. Security Attributes: IsGranted and Security
The PHP attribute #[IsGranted] from Symfony\Component\Security\Http\Attribute protects controller methods declaratively. #[IsGranted('ROLE_ADMIN')] checks the role of the logged-in user and automatically throws an AccessDeniedException if the check fails. #[IsGranted('EDIT', subject: 'product')] passes the route argument $product as a subject to the voter, so the voter class can perform ownership-based checks. The statusCode attribute controls the HTTP status on access denied: the default is 403, but 404 avoids leaking information about a resource's existence.
The more general #[Security] attribute accepts a full security expression: #[Security("is_granted('ROLE_USER') and user.isVerified()")]. It can be placed multiple times on a method, in which case all expressions must be true simultaneously (AND-combined). Both PHP attributes can be combined at class and method level: the class protects all methods with a base rule, individual methods override or extend it. The result is a readable security configuration placed directly on the code, without separate firewall rules in security.yaml for every endpoint.
8. Serializer Attributes: Groups, SerializedName, Ignore
The Symfony serializer PHP attributes control how objects are serialized and deserialized. #[Groups(['product:read', 'product:list'])] on a property marks it for particular serialization groups, only fields whose groups match the active context appear in the output. This prevents information leakage and enables optimized payload sizes for list vs. detail views without separate DTOs. #[SerializedName('product_name')] maps a PHP property name onto a different JSON key, useful for backward compatibility or API conventions that expect camelCase turned into snake_case.
#[Ignore] excludes a property from serialization entirely, even if a group is active. This is the more precise alternative to omitted group annotations and makes the intent explicit. #[Context] injects serialization context values directly onto the property: #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] formats a DateTimeInterface field with the given format, independent of the global context. This granular control makes property-level formatting possible without a custom normalizer for every date format. In API Platform projects, these serializer PHP attributes complement the resource configuration and control the output format precisely.
9. Attributes vs. YAML: When to Use What?
Migrating from YAML to PHP attributes is advisable in Symfony 7 for most configurations, but not sensible in every case. The decision rests on the proximity of the configuration to the code: attributes that describe a single class or method directly belong in PHP, attributes that control system-wide settings are still better placed in YAML.
| Configuration | PHP Attribute | YAML Preferred | Rationale |
|---|---|---|---|
| Routes | #[Route] ✓ | Possible | Directly on the controller, no YAML sync needed |
| Firewall / Security | Supplementary | security.yaml ✓ | Global rules are better kept central in YAML |
| Services / DI | #[Autowire] ✓ | Fallback | Explicit injection visible on the constructor |
| Event Listener | #[AsEventListener] ✓ | Legacy | Listener class is self-documenting |
| Bundle Configuration | Not possible | config/packages/*.yaml ✓ | Bundles configure themselves via YAML |
The rule of thumb for PHP attributes in Symfony 7: everything that directly concerns a single class or method becomes an attribute in PHP. Everything that concerns several classes or the entire system stays in YAML. Bundle configurations, global firewall rules and system-wide cache settings have their rightful place in YAML files, not as attributes on a single class. With this dividing line, you benefit from both approaches: compact, self-documenting PHP classes and clear, central configuration files for system-wide settings.
Mironsoft
Symfony 7 development, migration and PHP 8.4 modernization
Ready to modernize your Symfony project to PHP attributes and Symfony 7?
We migrate existing Symfony projects from YAML/annotation configuration to native PHP attributes in Symfony 7, for more compact, more maintainable and IDE-friendly code with PHP 8.4.
Migration Audit
Analysis of existing YAML/annotation configuration and a migration plan for Symfony 7 PHP attributes
Code Modernization
Migration to PHP 8.4 constructor promotion, attributes and Symfony 7 best practices
Team Training
Workshop on Symfony 7 PHP attributes, DI patterns and clean architecture for developer teams
10. Summary
Symfony 7 PHP attributes replace YAML and Doctrine annotations for all class- and method-related configuration. #[Route] defines routes directly on the controller. #[Autowire], #[AutowireIterator] and #[AsTaggedItem] control dependency injection without services.yaml. #[AsEventListener] and #[AsMessageHandler] register event listeners and Messenger handlers declaratively. #[MapQueryString] and #[MapRequestPayload] map request data into typed DTOs. #[IsGranted] protects endpoints. #[Groups] and #[SerializedName] control serialization.
The pattern behind all PHP attributes in Symfony 7 is consistent: configuration belongs close to the code it describes. That reduces the context switch between a PHP class and a YAML file, makes configuration navigable for the IDE and prevents orphaned configuration for deleted classes. For system-wide settings such as bundle configuration and global firewall rules, YAML remains the right place. Everything in between is better expressed with PHP attributes in Symfony 7.
Symfony 7 Attribute Cheatsheet, the Essentials at a Glance
Routing & Request
#[Route] for endpoints, #[MapQueryString] for query parameters, #[MapRequestPayload] for the body, #[MapUploadedFile] for file uploads.
Dependency Injection
#[Autowire] for services and parameters, #[AutowireIterator] for tagged collections, #[AsAlias] and #[AsDecorator] for service configuration.
Events & Messenger
#[AsEventListener] for event listeners with priority, #[AsMessageHandler] for Messenger handlers, both without services.yaml.
Security & Serializer
#[IsGranted] for access control, #[Groups] for serialization groups, #[SerializedName] for JSON key mapping.