Laravel 13 PHP Attributes: Cleaner Models, Jobs, and Commands
Every Laravel job I've written for the past five years starts the same way: three or four protected properties before a single line of real logic. Queue name, retry count, timeout. It works, but it's background noise. Laravel 13 PHP attributes offer an opt-in alternative that moves all of that above the class declaration and out of the method body area entirely.
If you're still working through the Laravel 12 to 13 upgrade process, this is one of the quality-of-life changes worth understanding before you start.
PHP Attributes: A One-Paragraph Recap
PHP attributes (#[...]) have been available since PHP 8.0. They are metadata annotations you attach to classes, methods, or properties, and are read at runtime via reflection. Laravel 13 handles that reflection internally — you just add the annotation, and the framework picks it up. The only requirement is PHP 8.3+. Existing property-based syntax still works with no deprecation warnings, so this is entirely additive.
Laravel 13 PHP Attributes in Artisan Commands
Before, a command's signature and description lived as protected properties:
class SendWelcomeEmail extends Command
{
protected $signature = 'emails:welcome {user}';
protected $description = 'Send a welcome email to a new user';
public function handle(): void
{
// ...
}
}
With Laravel 13 command attributes:
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Attributes\Usage;
#[Signature('emails:welcome {user}')]
#[Description('Send a welcome email to a new user')]
#[Usage('emails:welcome 42')]
class SendWelcomeEmail extends Command
{
public function handle(): void
{
// handle() is now the first thing you see
}
}
Additional console attributes worth knowing: #[Help] for extended help text, and #[Hidden] (Illuminate\Console\Attributes\Hidden) to hide a command from php artisan list — handy for internal maintenance commands you don't want surfacing to other developers.
Laravel 13 PHP Attributes in Queued Jobs
This is where the ergonomic gain is most noticeable. A typical queued job before Laravel 13:
class ProcessPayment implements ShouldQueue
{
public string $queue = 'high';
public string $connection = 'redis';
public int $tries = 3;
public int $timeout = 120;
public int $maxExceptions = 2;
public function handle(): void
{
// actual logic buried after configuration
}
}
The same class using Laravel 13 PHP attributes for queued jobs:
use Illuminate\Queue\Attributes\Connection;
use Illuminate\Queue\Attributes\FailOnTimeout;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Attributes\Queue;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;
#[Queue('high')]
#[Connection('redis')]
#[Tries(3)]
#[Timeout(120)]
#[MaxExceptions(2)]
#[FailOnTimeout]
class ProcessPayment implements ShouldQueue
{
public function handle(): void
{
// logic is front and centre
}
}
The handle() method is now the first thing you see when opening the file. One other attribute worth highlighting: #[Backoff] accepts variadic delay values — #[Backoff(10, 30, 60)] — giving you exponential-style retry spacing without writing a backoff() method.
If you're doing queue routing at the application level, pair this with Laravel 13's Queue::route() for centralising queue configuration. And if you're running workers in production, the complete guide to Laravel queues covers how these attributes interact with Horizon and Supervisor.
Laravel 13 PHP Attributes in Eloquent Models
Model attributes work at the class level and replace the property arrays at the top of your model:
// Before: Laravel 12 and earlier
class Post extends Model
{
protected $fillable = ['title', 'body', 'published_at'];
protected $hidden = ['internal_notes'];
protected $table = 'blog_posts';
protected $connection = 'mysql_read';
}
// After: Laravel 13 PHP attributes
use Illuminate\Database\Eloquent\Attributes\Connection;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Attributes\Table;
#[Table('blog_posts')]
#[Connection('mysql_read')]
#[Fillable(['title', 'body', 'published_at'])]
#[Hidden(['internal_notes'])]
class Post extends Model
{
}
The full set of Eloquent model attributes in Laravel 13: #[Appends], #[Connection], #[Fillable], #[Guarded], #[Hidden], #[Table], #[Touches], #[Unguarded], and #[Visible].
Note that cast configuration is still per-property rather than class-level. If you're already using PHP 8.4 property hooks in your Laravel models, attribute syntax feels like a natural companion — both push configuration toward the property declaration. If you've been replacing string constants with PHP backed enums in your models, enum casts still work exactly the same way alongside class-level attributes.
Other Locations Worth Knowing
The attribute support in Laravel 13 extends well beyond the three main areas:
Controllers — #[Middleware('auth')] and #[Authorize('edit-post', only: ['edit', 'update'])] from Illuminate\Routing\Attributes\Controllers. Repeatable, so you can stack multiple middleware attributes.
Form Requests — #[StopOnFirstFailure], #[RedirectTo('/login')], and #[ErrorBag('registration')] replace the equivalent protected properties.
API Resources — #[PreserveKeys] and #[Collects(PostResource::class)] replace $preserveKeys and the $collects property.
Factories — #[UseModel(Post::class)] binds a factory to its model, removing the need for the $model property.
Testing — #[Seed], #[Seeder(DatabaseSeeder::class)], and #[SetUp]/#[TearDown] handle test class configuration without boilerplate setup methods.
Should You Switch?
It's opt-in. Old property syntax still works in Laravel 13 — there are no deprecation warnings, and nothing forces a migration.
Attributes make the most sense on small, focused classes. A job that does one thing, a command with a short signature — moving four property declarations above the class declaration genuinely reduces noise.
They help less with large models. A 300-line Eloquent model with complex casts, many relationships, and multiple scopes doesn't become easier to read just because $fillable is now #[Fillable([...])] at the top.
The one rule I'd enforce: don't mix old and new syntax in the same class. If you use #[Tries(3)] on a job, don't also define public int $tries = 3. The attribute takes precedence, but the duplication will confuse the next developer — and probably you in six months.
Gotchas and Edge Cases
PHPStan / Larastan — if you run Larastan at a high level, update to the latest version first. Older versions may not resolve the new attribute namespaces and will flag false positives on your migrated classes.
IDE support — PHPStorm (2025.1+) understands these attributes natively. VS Code with Intelephense is still catching up. Expect limited auto-complete on attribute parameters until your IDE adds explicit support.
#[Table] consolidates multiple properties — #[Table('posts', key: 'post_id', keyType: 'string', incrementing: false)] replaces what were previously three separate protected properties. If you're using composite keys or UUID primary keys, check this attribute carefully.
Backoff accepts multiple arguments — #[Backoff(10, 30, 60)] maps to the array you'd return from a backoff() method. It's variadic, so you're not limited to a fixed number of delays.
Wrapping Up
Laravel 13 PHP attributes are a surface-level change with a real quality-of-life payoff — particularly for job classes, where the signal-to-noise ratio genuinely improves. The old property syntax isn't going anywhere, so adoption can be incremental: new classes use attributes, existing classes stay as-is.
If you're preparing your app for the upgrade, start with the practical Laravel 12 to 13 upgrade guide. If queue reliability rather than ergonomics is what you're actually after, the complete Laravel queues production guide covers workers, Horizon, and failure handling in depth.
Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.