🧩 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, $casts) :
use Illuminate\Database\Eloquent\Attributes\Casts;
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(name: 'invoices', primaryKey: 'id', timestamps: true)]
#[Fillable(['amount', 'customer_email', 'notes'])]
#[Hidden(['internal_token'])]
#[Visible(['id', 'amount', 'customer_email', 'paid_at'])]
#[Casts(['amount' => 'decimal:2', 'paid_at' => 'datetime'])]
#[ObservedBy([InvoiceObserver::class])]
#[ScopedBy([ActiveScope::class])]
#[CollectedBy(InvoiceCollection::class)]
#[UseEloquentBuilder(InvoiceBuilder::class)]
#[UseFactory(InvoiceFactory::class)]
#[UsePolicy(InvoicePolicy::class)]
final class Invoice extends Model
{
// ...
}Effet immédiat : en ouvrant Invoice.php, on connaît la table, les colonnes mass-assignées, les colonnes cachées, les casts, 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
Le système de queues remplace l’ancien lot de propriétés (public $tries, public $timeout, public $backoff, etc.) par des attributs dédiés :
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\Connection;
use Illuminate\Queue\Attributes\FailOnTimeout;
use Illuminate\Queue\Attributes\Queue;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\Attributes\UniqueFor;
#[Connection('redis')]
#[Queue('billing')]
#[Tries(5)]
#[Timeout(60)]
#[Backoff([10, 30, 60, 120, 300])]
#[FailOnTimeout]
#[UniqueFor(300)]
final class SendInvoiceEmail
{
public function __construct(public readonly string $invoiceId) {}
public function __invoke(InvoiceRepository $repo, Mailer $mailer): void { /* ... */ }
}Toutes les caractéristiques d’exécution du job tiennent en haut de la classe, lisibles d’un coup d’œil.
🛠️ Commandes Artisan
Les commandes Artisan profitent du même traitement. Plus besoin d’override $signature et $description, ni de déclarer la planification dans Kernel.php :
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Scheduled;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Attributes\WithoutOverlapping;
#[Signature('invoices:dispatch-reminders {--days=7}')]
#[Description('Envoie les relances de paiement aux clients en retard.')]
#[Scheduled('daily at 09:00')]
#[WithoutOverlapping]
final class DispatchInvoiceRemindersCommand extends Command
{
public function handle(InvoiceReminderService $service): int { /* ... */ }
}Effet collatéral très agréable : Kernel.php devient quasiment vide. La planification vit dans la commande qu’elle pilote.
🛣️ Contrôleurs : middleware, autorisation, validation, binding
Sur les contrôleurs, plusieurs attributs cohabitent et se complètent :
use Illuminate\Auth\Access\Attributes\Authorize;
use Illuminate\Http\Attributes\MapWith;
use Illuminate\Http\Attributes\Validate;
use Illuminate\Routing\Attributes\Middleware;
#[Middleware(['auth:sanctum', 'verified'])]
final class InvoiceController
{
#[Authorize('view', 'invoice')]
public function show(#[MapWith('uuid')] Invoice $invoice): InvoiceResource
{
return new InvoiceResource($invoice);
}
public function store(
#[Validate('required|string|max:120')] string $reference,
#[Validate('required|numeric|min:0')] float $amount,
): InvoiceResource {
return new InvoiceResource(Invoice::create(compact('reference', 'amount')));
}
#[Authorize('delete', 'invoice')]
public function destroy(Invoice $invoice): Response { /* ... */ }
}#[Middleware]applique un middleware au niveau de la classe ou de la méthode.#[Authorize]joue le rôle d’authorize()au plus près de l’action.#[Validate]permet une validation inline sur les paramètres typés.#[MapWith]indique sur quel attribut faire le route-model binding (par exempleuuidau lieu de l’id).
📦 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.