12 min read

Laravel 13 PHP Attributes: Refactor Your Models, Jobs, and Commands in 10 Minutes

A hands-on guide to replacing $fillable, $hidden, $connection, and other class properties with PHP 8 Attributes in Laravel 13.

Laravel 13 PHP Attributes: Refactor Your Models, Jobs, and Commands in 10 Minutes

Every Laravel model you've ever written starts the same way. A wall of protected arrays sitting above your actual business logic. $fillable, $hidden, $guarded, $table, $connection. You know the drill.

I counted my last project. 23 models. Every single one had at least four property declarations before the first method. Some had eight or nine. That's not configuration. That's clutter.

Laravel 13 changes that. Dropping March 17, PR #58578 introduces PHP 8 Attributes as a first-class alternative to class properties across your entire application. Models, queue jobs, artisan commands, form requests, even API resources and factories. And the best part? It's completely non-breaking. Your existing code works exactly as before.

But here's what nobody's showing you: how to actually take an existing Laravel app and start using these attributes once you upgrade. Not a feature list. Not a changelog recap. A practical, "open your editor and do this" guide.

Let's refactor some real code.

What Are PHP Attributes (and Why Should You Care)?

PHP Attributes landed in PHP 8.0, but Laravel mostly ignored them. Sure, there were a few scattered uses, but the framework still relied heavily on class properties for configuration. That created an awkward split where some things used attributes and others used protected $whatever.

Taylor Otwell addressed this directly in the PR description: the framework had fallen into an inconsistent state where attributes handled some configuration while properties handled the rest. Laravel 13 fixes that by giving you attribute-based alternatives for pretty much everything.

So why bother switching? Three reasons.

First, your configuration moves from "hidden inside the class body" to "declared right on the class itself." When you open a file, you see what the class does before you read a single method. That's a meaningful readability win, especially on models with 15+ properties.

Second, PHP Attributes are native language features. Your IDE already understands them. PhpStorm and VS Code will autocomplete them, validate their arguments, and let you click through to the source. No magic strings, no guessing.

Third, and this is the part most people miss: the #[Unguarded] attribute has no property-based equivalent. It's the first attribute-only feature. That signals a clear direction. Future Laravel capabilities will land as attributes first. Getting comfortable with the syntax now means you won't be playing catch-up later.

Refactoring Eloquent Models

This is where most developers will feel the biggest impact. Let's start simple and work up to a complex example.

A Simple Model First

Most of your models probably look something like this:

class Post extends Model
{
    protected $fillable = ['title', 'body', 'slug', 'published_at'];
    protected $hidden = ['deleted_at'];
}

The attribute version:

#[Fillable(['title', 'body', 'slug', 'published_at'])]
#[Hidden(['deleted_at'])]
class Post extends Model
{
}

Two lines. Configuration sits above the class, not inside it. Your class body is now empty, which means any methods you add are immediately the focus. No scrolling past property declarations.

Now let's look at a more complex model.

Before: The Property Approach

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class User extends Model
{
    use HasFactory;

    protected $table = 'users';
    protected $primaryKey = 'user_id';
    protected $keyType = 'string';
    public $incrementing = false;
    public $timestamps = true;
    protected $connection = 'mysql';
    protected $dateFormat = 'Y-m-d H:i:s';

    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected $visible = [
        'name',
        'email',
        'created_at',
    ];

    protected $appends = [
        'full_name',
    ];

    protected $touches = ['posts'];

    // ... your actual methods start way down here
}

That's 30+ lines of configuration before you get to a single relationship or accessor. On complex models, this gets worse. Much worse.

After: The Attribute Approach

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Attributes\PrimaryKey;
use Illuminate\Database\Eloquent\Attributes\KeyType;
use Illuminate\Database\Eloquent\Attributes\Connection;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Attributes\Visible;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Touches;
use Illuminate\Database\Eloquent\Attributes\WithoutIncrementing;
use Illuminate\Database\Eloquent\Attributes\DateFormat;

#[Table('users')]
#[PrimaryKey('user_id')]
#[KeyType('string')]
#[WithoutIncrementing]
#[Connection('mysql')]
#[DateFormat('Y-m-d H:i:s')]
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
#[Visible(['name', 'email', 'created_at'])]
#[Appends(['full_name'])]
#[Touches(['posts'])]
class User extends Model
{
    use HasFactory;

    // Your methods start right here. No property wall.
}

Yes, the import block is longer. But look at what you get: every configuration decision is visible the moment you open the file. You can scan the attribute list in two seconds and know exactly how this model behaves. No scrolling, no hunting through properties.

The Complete Model Attributes List

Here's every Eloquent attribute available in Laravel 13:

#[Table('table_name')] replaces protected $table. You can also pass named arguments for related config: #[Table('users', key: 'user_id', keyType: 'string', incrementing: false)] if you want to bundle everything into a single attribute.

#[PrimaryKey('column')] replaces protected $primaryKey.

#[KeyType('string')] replaces protected $keyType.

#[Connection('mysql')] replaces protected $connection.

#[Incrementing] and #[WithoutIncrementing] replace public $incrementing = true/false.

#[Timestamps] and #[WithoutTimestamps] replace public $timestamps = true/false.

#[DateFormat('Y-m-d')] replaces protected $dateFormat.

#[Fillable(['name', 'email'])] replaces protected $fillable.

#[Guarded(['id'])] replaces protected $guarded.

#[Unguarded] is new. No property equivalent. It sets the model to completely unguarded without needing Model::unguard() in your service provider.

#[Hidden(['password'])] replaces protected $hidden.

#[Visible(['name', 'email'])] replaces protected $visible.

#[Appends(['full_name'])] replaces protected $appends.

#[Touches(['post'])] replaces protected $touches.

A Realistic Migration Strategy

You don't need to refactor everything at once. Here's what I'd actually do:

Start with new models. Once you're on Laravel 13, any new model you create should use attributes. That's the easy win.

For existing models, convert them when you're already touching the file. Fixing a bug in your Order model? Take five extra minutes to swap the properties for attributes while you're in there.

Don't batch-convert 50 models in one PR. Your team will hate reviewing that, and if something breaks, you'll have no idea which model caused it. If you're working with a large codebase and well-structured design patterns, gradual migration keeps things manageable.

Refactoring Queue Jobs

Queue configuration is the second big win. If you've built any serious background processing (and if you haven't, you probably should be), your job classes are full of queue-related properties.

Before

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class ProcessPodcast implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $connection = 'redis';
    public $queue = 'podcasts';
    public $tries = 3;
    public $timeout = 120;
    public $backoff = 60;
    public $maxExceptions = 2;
    public $deleteWhenMissingModels = true;

    public function __construct(
        public Podcast $podcast,
    ) {}

    public function handle(): void
    {
        // Process the podcast
    }
}

After

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\Attributes\Connection;
use Illuminate\Queue\Attributes\Queue;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\MaxExceptions;
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;

#[Connection('redis')]
#[Queue('podcasts')]
#[Tries(3)]
#[Timeout(120)]
#[Backoff(60)]
#[MaxExceptions(2)]
#[DeleteWhenMissingModels]
class ProcessPodcast implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Podcast $podcast,
    ) {}

    public function handle(): void
    {
        // Process the podcast
    }
}

The constructor is immediately visible now. No seven lines of public $whatever sitting between the class declaration and the constructor. That matters when you're scanning 40 job classes trying to find the one with the wrong timeout.

Queue Attributes Reference

#[Connection('redis')] replaces public $connection.

#[Queue('podcasts')] replaces public $queue.

#[Tries(3)] replaces public $tries.

#[Timeout(120)] replaces public $timeout.

#[Backoff(60)] replaces public $backoff. You can also pass an array for exponential backoff: #[Backoff([30, 60, 120])].

#[MaxExceptions(2)] replaces public $maxExceptions.

#[UniqueFor(3600)] replaces public $uniqueFor.

#[FailOnTimeout] replaces public $failOnTimeout = true.

#[DeleteWhenMissingModels] replaces public $deleteWhenMissingModels = true.

These same attributes work on event listeners, notifications, mailables, and broadcast events. Anywhere you implement ShouldQueue, you can use them.

Refactoring Artisan Commands

Commands get a smaller but equally clean upgrade.

Before

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;

class SendNewsletter extends Command
{
    protected $signature = 'newsletter:send {segment} {--force}';
    protected $description = 'Send the weekly newsletter to a subscriber segment';

    public function handle(): int
    {
        // Send the newsletter
        return Command::SUCCESS;
    }
}

After

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Attributes\Description;

#[Signature('newsletter:send {segment} {--force}')]
#[Description('Send the weekly newsletter to a subscriber segment')]
class SendNewsletter extends Command
{
    public function handle(): int
    {
        // Send the newsletter
        return Command::SUCCESS;
    }
}

Two properties gone. The class body now contains only your handle() method. Clean.

This is especially nice when you have commands with long signatures. The $signature property with complex argument definitions gets awkward. Having it as an attribute keeps the focus on the class-level contract rather than burying it inside the body.

If you've got a lot of artisan commands (and most mature Laravel apps do), this cleanup adds up fast. One project I worked on had 30+ custom commands. Each one with two boilerplate properties. That's 60 lines of configuration noise gone.

When NOT to Use Attributes

Before you go converting everything, there are a few situations where properties still make more sense.

Dynamic configuration. If your model's table name or connection depends on runtime logic (multi-tenant apps where the connection changes per request, for example), you need a method or property override. Attributes are static by definition. They're resolved once and cached. You can't pass a variable to #[Connection($tenantConnection)] because attribute arguments must be constant expressions.

Trait-provided properties. If you have a trait like HasUuid that sets protected $keyType = 'string' and public $incrementing = false, keep using properties inside the trait. PHP Attributes on traits don't automatically transfer to the class using them. Your trait would need to document that consumers should add #[KeyType('string')] themselves, which defeats the purpose.

Team readiness. If your team isn't on PHP 8.3 yet, or half the team hasn't seen attribute syntax before, forcing the switch will cause more confusion than it solves. The properties work fine. This is a readability improvement, not a bug fix. Introduce it gradually.

For everything else, attributes are strictly better. They're more scannable, more explicit, and they align with where PHP itself is heading.

Form Requests, API Resources, and More

Laravel 13 doesn't stop at models, jobs, and commands. Here are the other areas where attributes are available.

Form Requests

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Foundation\Http\Attributes\RedirectTo;
use Illuminate\Foundation\Http\Attributes\StopOnFirstFailure;

#[RedirectTo('/posts/create')]
#[StopOnFirstFailure]
class StorePostRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'body' => ['required', 'string'],
        ];
    }
}

If you're building production-grade APIs, your form requests stay focused on validation rules without redirect configuration mixed in.

API Resources

use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Http\Resources\Attributes\Collects;
use Illuminate\Http\Resources\Attributes\PreserveKeys;

#[Collects(UserResource::class)]
#[PreserveKeys]
class UserCollection extends ResourceCollection
{
    // ...
}

Factories

use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Factories\Attributes\UseModel;

#[UseModel(User::class)]
class UserFactory extends Factory
{
    public function definition(): array
    {
        return [
            'name' => fake()->name(),
            'email' => fake()->unique()->safeEmail(),
        ];
    }
}

Test Seeders

use Illuminate\Foundation\Testing\Attributes\Seed;
use Illuminate\Foundation\Testing\Attributes\Seeder;

#[Seeder(OrderSeeder::class)]
class OrderTest extends TestCase
{
    // Tests run with OrderSeeder data
}

What About Performance?

This question came up immediately in the PR discussion. Someone asked about the performance impact of reflection, since PHP needs to read these attributes at runtime.

The short answer: it's negligible. Reflection in PHP doesn't carry the overhead people assume. And attribute resolution happens once per class, not once per instance. The first time Laravel instantiates a model, it reads the attributes and caches the configuration. Every subsequent instance uses the cached values.

If you're running an app where microsecond-level model instantiation matters, you've got bigger architectural questions to answer. For the other 99.9% of applications, this isn't a concern.

The Migration Checklist

Ready to start? Here's the step-by-step process for converting a file:

Make sure you're on PHP 8.3+. Laravel 13 requires it as a minimum. Run php -v if you're not sure. If you're still on 8.2, that's your first task. Everything else depends on it.

Pick one file. A model, a job, a command. Not your whole app at once. I'd start with a model that has a lot of properties, since that's where the visual improvement is biggest.

Add the use imports for each attribute you need. Your IDE will help here. PhpStorm will auto-import them when you type the attribute name. VS Code with Intelephense does the same.

Move each property to its attribute equivalent on the class declaration. The mapping is one-to-one in every case except #[Unguarded], which is new.

Delete the old properties. Don't leave both. Having #[Fillable(['name'])] and protected $fillable = ['name'] in the same class is confusing, even though Laravel handles it fine.

Run your tests. Everything should pass because the behavior is identical. If something breaks, it's most likely a typo in the attribute argument, not a framework bug.

Commit with a clear message like "refactor: convert User model to PHP Attributes." Keep these conversions in their own commits so they're easy to review and revert if needed.

Move on to the next file when you're ready. No rush.

If you're building admin panels with Filament, the models powering those panels are great candidates for early conversion. They're typically well-tested and have stable property configurations.

Should You Use the Bundled Table Attribute?

One thing worth noting: the #[Table] attribute supports a bundled syntax where you combine table name, primary key, key type, and incrementing into a single attribute:

#[Table('users', key: 'user_id', keyType: 'string', incrementing: false)]

Or you can use individual attributes:

#[Table('users')]
#[PrimaryKey('user_id')]
#[KeyType('string')]
#[WithoutIncrementing]

My take? Use individual attributes. Yes, the bundled version is shorter. But individual attributes are easier to scan, easier to modify, and they match the pattern used everywhere else in the framework. Consistency wins over brevity.

FAQ

Do I need Laravel 13 to use PHP Attributes?

Yes. The framework-specific attributes like #[Table], #[Fillable], #[Connection], etc. are new in Laravel 13. Generic PHP 8 Attributes have existed since PHP 8.0, but these Laravel-specific ones require Laravel 13 (releasing March 17, 2026) and PHP 8.3+.

Can I mix properties and attributes in the same model?

Yes. The system is fully backward compatible. You could use #[Table('users')] as an attribute while keeping protected $fillable as a property. Laravel resolves both. That said, pick one style per file for consistency.

Will properties be deprecated?

Not anytime soon. The PR explicitly states this is non-breaking and properties remain fully supported. But the introduction of #[Unguarded] as an attribute-only feature hints at where things are heading. New features will likely land as attributes first.

Do attributes work with traits?

This is a limitation worth knowing. PHP Attributes on traits don't automatically apply to the class using the trait. If you're relying on traits to set model properties (like a HasUuid trait that sets $keyType = 'string'), you'll need to keep using properties in those traits. Attributes should be placed on the final class.

What happens with Laravel Shift?

Shift will likely offer automated conversion from properties to attributes once Laravel 13 is stable. But it's such a straightforward change that doing it manually as you go is perfectly fine. There's no urgent need to convert everything before upgrading.

Where This Is Heading

Laravel 13 might not have breaking changes, but it's sending a clear signal. The framework is aligning itself with native PHP features instead of building parallel abstractions. Attributes, enums, typed properties, readonly classes. PHP itself has gotten really good, and Laravel is leaning into that.

The #[Unguarded] attribute being attribute-only isn't an accident. It's a preview of how new features will ship. And if Laravel 14 or 15 introduces attribute-based route definitions or middleware configuration (something community packages like Spatie's route attributes have been doing for years), you'll already know the syntax.

I've been watching this pattern across the framework for a while now. The shift toward typed route bindings, constructor promotion, enums for status fields. Each release leans harder into what PHP gives you natively. Attributes are just the latest step in that direction, and probably the most visible one.

For now, start small. Convert one model. One job. See how it feels. I'd bet within a week you'll wonder why you ever tolerated those property walls.

Need help upgrading your Laravel application or building something new with Laravel 13? Let's talk.

Share: X/Twitter | LinkedIn |

Got a Product Idea?

I build MVPs, web apps, and SaaS platforms in 7 days. Fixed price, real code, deployed and ready to use.

⚡ Currently available for 2-3 new projects

Hafiz Riaz

About Hafiz

Full Stack Developer from Italy. I help founders turn ideas into working products fast. 9+ years of experience building web apps, mobile apps, and SaaS platforms.

View My Work →

Get web development tips via email

Join 50+ developers • No spam • Unsubscribe anytime