🧩 Laravel 13 : les attributs PHP qui changent le quotidien
📚 Introduction
Les attributs PHP existent depuis la 8.0, mais leur adoption dans Laravel s’est faite progressivement. Avec Laravel 13, le mouvement s’accélère : Eloquent, le système de queues, les commandes Artisan, les ressources API et l’autorisation embarquent désormais leur jeu d’attributs natifs. Le code devient plus déclaratif, mieux outillé par les IDE, et moins dépendant des conventions implicites.
Voici les principales familles d’attributs à connaître, regroupées par usage.
🗃️ Eloquent : modèles et collections
Côté modèle, plusieurs attributs remplacent les propriétés magiques ($table, $fillable, $hidden, $visible) :
use Illuminate\Database\Eloquent\Attributes\CollectedBy;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Attributes\UseEloquentBuilder;
use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Illuminate\Database\Eloquent\Attributes\UsePolicy;
use Illuminate\Database\Eloquent\Attributes\Visible;
#[Table('invoices')]
#[Fillable(['amount', 'customer_email', 'notes'])]
#[Hidden(['internal_token'])]
#[Visible(['id', 'amount', 'customer_email', 'paid_at'])]
#[ObservedBy([InvoiceObserver::class])]
#[ScopedBy([ActiveScope::class])]
#[CollectedBy(InvoiceCollection::class)]
#[UseEloquentBuilder(InvoiceBuilder::class)]
#[UseFactory(InvoiceFactory::class)]
#[UsePolicy(InvoicePolicy::class)]
final class Invoice extends Model
{
protected function casts(): array
{
return [
'amount' => 'decimal:2',
'paid_at' => 'datetime',
];
}
}Petite précision : pour les casts, Laravel 13 conserve la méthode casts() (introduite avant les attributs) comme API canonique — pas d’attribut #[Casts] côté framework. Mais en ouvrant Invoice.php, on connaît tout de même la table, les colonnes mass-assignées, les colonnes cachées, les observers, les global scopes, la factory, la policy, le custom builder et la custom collection — sans devoir aller fouiller dans un Service Provider.
🔍 Scopes Eloquent déclaratifs
Les scopes locaux profitent aussi de l’approche déclarative, sans le préfixe magique scopeXxx() :
use Illuminate\Database\Eloquent\Attributes\Scope;
final class Invoice extends Model
{
#[Scope]
public function paid(Builder $query): void
{
$query->whereNotNull('paid_at');
}
#[Scope(as: 'recent')]
public function publishedRecently(Builder $query): void
{
$query->where('created_at', '>=', now()->subDays(7));
}
}Invoice::query()->paid()->recent()->get();🚀 Jobs et queues
Côté queues, Laravel 13 introduit un lot d’attributs pour remplacer les propriétés magiques (public $tries, public $timeout, etc.) :
use Illuminate\Queue\Attributes\DebounceFor;
use Illuminate\Queue\Attributes\FailOnTimeout;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\Attributes\UniqueFor;
use Illuminate\Queue\Attributes\WithoutRelations;
#[Tries(5)]
#[Timeout(60)]
#[MaxExceptions(3)]
#[FailOnTimeout]
#[UniqueFor(300)]
#[DebounceFor(30)]
#[WithoutRelations]
final class SendInvoiceEmail implements ShouldQueue
{
public function __construct(public readonly string $invoiceId) {}
public function handle(InvoiceRepository $repo, Mailer $mailer): void { /* ... */ }
}Note importante : le choix de la connexion et de la queue reste piloté par les méthodes onConnection() / onQueue() au dispatch (ou par défaut dans config/queue.php), pas par des attributs sur la classe. Cela laisse les jobs portables d’un environnement à l’autre. Le backoff se définit toujours via une méthode backoff() sur la classe, qui retourne un entier ou un tableau d’entiers.
Le reste — tentatives, timeouts, debouncing, unicité, comportement face aux relations Eloquent — tient désormais en haut de la classe, lisible d’un coup d’œil.
🛠️ Commandes Artisan
Les commandes Artisan profitent du même traitement. Plus besoin d’override $signature et $description : les attributs #[Signature] et #[Description] remplacent les propriétés magiques.
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('invoices:dispatch-reminders {--days=7}')]
#[Description('Envoie les relances de paiement aux clients en retard.')]
final class DispatchInvoiceRemindersCommand extends Command
{
public function handle(InvoiceReminderService $service): int { /* ... */ }
}Pour la planification, Laravel 13 conserve la déclaration via Schedule::command(...) dans routes/console.php (ou un Service Provider). C’est l’endroit centralisé qui répertorie tous les jobs récurrents — pratique pour avoir une vue d’ensemble, même si certains projets aimeraient un #[Scheduled('daily at 09:00')] directement sur la classe.
🛣️ Contrôleurs : middleware et autorisation
Sur les contrôleurs, deux attributs cohabitent et se complètent :
use Illuminate\Routing\Attributes\Controllers\Authorize;
use Illuminate\Routing\Attributes\Controllers\Middleware;
#[Middleware('auth:sanctum')]
#[Middleware('verified')]
final class InvoiceController
{
#[Authorize('view', 'invoice')]
public function show(Invoice $invoice): InvoiceResource
{
return new InvoiceResource($invoice);
}
#[Middleware('throttle:write')]
public function store(StoreInvoiceRequest $request): InvoiceResource
{
return new InvoiceResource(Invoice::create($request->validated()));
}
#[Authorize('delete', 'invoice')]
public function destroy(Invoice $invoice): Response { /* ... */ }
}#[Middleware](Illuminate\Routing\Attributes\Controllers\Middleware) applique un middleware au niveau de la classe ou de la méthode. On peut passeronly:/except:ou même une closure. Pour conserver la cohérence avec l’API non-attribut, la version “interface” (implements HasMiddleware+public static function middleware(): array) reste supportée et coexiste sans souci.#[Authorize](Illuminate\Routing\Attributes\Controllers\Authorize) joue le rôle d’authorize()au plus près de l’action. Le premier argument est l’ability, le second la cible (model class, paramètre de route, tableau pour un appel multi-arguments à la policy).
Pour la validation des entrées, on garde les FormRequest (StoreInvoiceRequest ici) comme API canonique côté Laravel : ils restent la meilleure façon de garder validation, autorisation et messages cohérents. Le route-model binding personnalisé (par exemple sur uuid) se déclare comme avant via Route::model() ou les méthodes resolveRouteBinding() / getRouteKeyName() sur le modèle.
📦 Ressources API : #[Collects]
Pour les ressources API, l’attribut #[Collects] remplace la propriété statique public static $collects :
use Illuminate\Http\Resources\Attributes\Collects;
#[Collects(Invoice::class)]
final class InvoiceCollection extends ResourceCollection
{
public function toArray($request): array
{
return ['data' => $this->collection];
}
}Plus besoin de réfléchir à la convention de nommage : la classe ciblée par la collection est explicitement annotée.
🧠 Côté Livewire : un écosystème d’attributs à part entière
L’autre acteur majeur de cette bascule, c’est Livewire 4. Le framework s’appuie massivement sur ses propres attributs pour piloter les composants :
#[Computed]pour les propriétés dérivées avec mémoïsation.#[Locked]pour les propriétés interdites en édition côté client.#[On('event-name')]pour les listeners d’événements.#[Lazy]pour les composants chargés à la demande.#[Validate('required|email')]pour la validation inline d’une propriété.
final class InvoiceList extends Component
{
#[Locked]
public int $userId;
#[Validate('required|string|min:2')]
public string $search = '';
#[Computed]
public function invoices()
{
return Invoice::query()
->where('user_id', $this->userId)
->where('reference', 'like', "%{$this->search}%")
->get();
}
#[On('invoice-created')]
public function refresh(): void {}
}Ces attributs ne sont pas dans Laravel core, mais ils participent du même mouvement : le contrat du composant est entièrement lisible depuis ses signatures. Pour un projet Laravel qui utilise Livewire (notamment avec Filament), le résultat global est très cohérent.
🧰 Quelques bonnes pratiques
- Ne pas tout convertir en attribut. Les
FormRequest, les Service Providers et la configuration globale gardent leur place. Les attributs brillent sur les petits cas où la séparation introduisait plus de complexité que de valeur. - Tester les attributs comme du code. PhpStorm et VS Code détectent la majorité des erreurs, mais un test de fumée reste utile sur les configurations critiques (jobs, scheduling, autorisation).
- Documenter ses propres attributs. Un attribut métier (rôles, permissions, audit) sans README court devient vite illisible pour les nouveaux arrivants.
🎉 Conclusion
Laravel 13 confirme une tendance qui s’amorçait depuis quelques versions : remplacer les conventions implicites par des attributs explicites. Le code devient plus déclaratif, plus lisible, mieux outillé par les IDE. La courbe d’apprentissage pour les nouveaux développeurs s’aplatit, parce qu’il y a moins de “magie” à connaître pour comprendre comment un modèle, un job ou une commande est configuré.
Pas besoin de tout migrer en une fois : profitez de chaque nouveau fichier pour adopter la syntaxe attributs, et laissez l’ancien code respirer jusqu’à la prochaine refonte.
🔗 Liens utiles
/faq
Questions fréquentes
Quels attributs Laravel 13 apporte-t-il pour Eloquent ?
+
Laravel 13 propose des attributs natifs qui remplacent les propriétés magiques du modèle, comme #[Table], #[Fillable], #[Hidden], #[Visible], #[ObservedBy], #[ScopedBy], #[UsePolicy] ou #[UseFactory], rendant la configuration d'un modèle lisible directement dans son fichier.
Les casts Eloquent utilisent-ils un attribut dans Laravel 13 ?
+
Non : pour les casts, Laravel 13 conserve la méthode casts() comme API canonique. Il n'existe pas d'attribut #[Casts] côté framework.
Comment configurer un job de queue avec les attributs en Laravel 13 ?
+
On place des attributs en haut de la classe du job, comme #[Tries(5)], #[Timeout(60)], #[MaxExceptions(3)] ou #[UniqueFor(300)]. En revanche, la connexion et la queue restent pilotées par onConnection() / onQueue() et le backoff par une méthode backoff().