Back to all articles
#laravel#php#eloquent

Laravel 13 Model Attributes Are Here — Goodbye protected $fillable

Laravel 13 ditches old-school protected properties for PHP 8 Attributes on Eloquent models. Here's how Fillable, Hidden, Guarded, and friends work now.

📅 Published: July 1, 2026✍️ Author: Muhammad Amirul Ihsan

The Property Party Is Over 🎉

For as long as I can remember, every Laravel model looked like a wall of protected properties:

class User extends Model
{
    protected $table = 'users';
    protected $fillable = ['name', 'email', 'password'];
    protected $hidden = ['password', 'remember_token'];
    protected $guarded = ['id'];
    protected $connection = 'mysql';
    public $timestamps = false;
    // ...and on and on
}

You’d scroll past this boilerplate every time you opened a model. It worked fine. It got the job done. But let’s be real — it wasn’t particularly elegant. Just a bunch of magic strings hanging out in the class body like laundry on a line.

Laravel 13 changes the game. The framework has fully embraced PHP 8 Attributes, and honestly? The models look so much cleaner now.


Meet the New Crew: PHP 8 Attributes

Instead of declaring properties, you now slap attributes right on the class. Here’s the same User model rewritten for Laravel 13:

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Attributes\WithoutTimestamps;
use Illuminate\Database\Eloquent\Attributes\Connection;
use Illuminate\Database\Eloquent\Model;

#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
#[Connection('mysql')]
#[WithoutTimestamps]
class User extends Model
{
    //
}

Look at that. The model body is practically empty. All the configuration lives in clean, declarative attributes above the class. Your IDE can autocomplete them, refactor them, and static analysis tools actually understand them. No more magic $fillable strings that tools like PHPStan had to special-case.


Fillable: The One You’ll Use Every Day

Old way:

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

New way:

use Illuminate\Database\Eloquent\Attributes\Fillable;

#[Fillable(['name', 'email', 'bio'])]
class Post extends Model
{
    //
}

Same behavior. You call Post::create([...]) or $post->fill([...]), and only the attributes you listed get through. Everything else is silently discarded (unless you’ve turned on preventSilentlyDiscardingAttributes in your AppServiceProvider, which you should in local dev).

JSON columns? Just use dot notation like before:

#[Fillable(['title', 'options->enabled', 'metadata->seo_title'])]
class Post extends Model
{
    //
}

Guarded: The “Blocklist” Approach

If you’d rather list what’s not fillable instead of what is:

use Illuminate\Database\Eloquent\Attributes\Guarded;

#[Guarded(['id', 'is_admin'])]
class User extends Model
{
    //
}

Everything except id and is_admin can be mass-assigned. Use with caution — it’s easy to accidentally expose columns when you add new ones to the database.


Unguarded: Living Dangerously

Want to allow everything? You absolute rebel:

use Illuminate\Database\Eloquent\Attributes\Unguarded;

#[Unguarded]
class Flight extends Model
{
    //
}

This is equivalent to the old protected $guarded = []. It’s fine for simple apps or prototypes, but if you go this route, please be disciplined about what arrays you pass to create() and fill(). A rogue is_admin in a request body and you’re having a bad day.


Hidden: Keep Your Secrets

Old way:

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

New way:

use Illuminate\Database\Eloquent\Attributes\Hidden;

#[Hidden(['password', 'remember_token', 'credit_card_number'])]
class User extends Model
{
    //
}

When you return a model as JSON (API response, toArray(), etc.), hidden attributes are stripped out. You can still access them in code with $user->password — they’re just excluded from serialization.

You can also temporarily make hidden attributes visible at runtime:

return $user->makeVisible('email')->toArray();

Or the opposite — hide something that’s normally visible:

return $user->makeHidden('salary')->toArray();

There’s also a #[Visible] attribute that works as an allowlist (everything not listed gets hidden):

use Illuminate\Database\Eloquent\Attributes\Visible;

#[Visible(['first_name', 'last_name', 'email'])]
class User extends Model
{
    //
}

Bonus Round: All the Other New Attributes

Laravel 13 didn’t stop at fillable/hidden/guarded. Here’s every model configuration attribute available now:

Table & Keys

use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Attributes\WithoutIncrementing;

#[Table('my_flights')]                          // was: protected $table
#[Table(key: 'flight_id')]                      // was: protected $primaryKey
#[Table(key: 'uuid', keyType: 'string', incrementing: false)]  // combo!
#[WithoutIncrementing]                          // was: public $incrementing = false

Timestamps

use Illuminate\Database\Eloquent\Attributes\WithoutTimestamps;
use Illuminate\Database\Eloquent\Attributes\DateFormat;

#[WithoutTimestamps]                            // was: public $timestamps = false
#[DateFormat('U')]                              // was: protected $dateFormat = 'U'
#[Table(timestamps: false, dateFormat: 'U')]    // or combine on the Table attribute

Database Connection

use Illuminate\Database\Eloquent\Attributes\Connection;

#[Connection('mysql')]                          // was: protected $connection = 'mysql'

Local Scopes

Old way: prefix your method with scope.

New way: slap #[Scope] on it. Bonus: methods can now be protected:

use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;

class User extends Model
{
    #[Scope]
    protected function popular(Builder $query): void
    {
        $query->where('votes', '>', 100);
    }

    #[Scope]
    protected function ofType(Builder $query, string $type): void
    {
        $query->where('type', $type);
    }
}

// Usage is exactly the same:
User::popular()->ofType('admin')->get();

Global Scopes & Observers

use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;

#[ScopedBy([AncientScope::class])]
#[ObservedBy([UserObserver::class])]
class User extends Model
{
    //
}

No more booted method or AppServiceProvider registration needed.

Appended Accessors

use Illuminate\Database\Eloquent\Attributes\Appends;

#[Appends(['is_admin', 'full_name'])]
class User extends Model
{
    //
}

Same behavior as the old protected $appends.


Why This Matters

Beyond looking cleaner, attributes bring real benefits:

  1. IDE support. Your editor understands #[Fillable(...)] natively. Rename refactors work. “Find usages” works. No more guessing if your IDE plugin knows what $fillable is supposed to be.

  2. Static analysis. Tools like PHPStan and Psalm can validate attribute arguments without custom extensions. You’ll catch typos in fillable fields before they hit production.

  3. Discoverability. #[Fillable] autocompletes in your IDE. A new developer on the project doesn’t need to memorize which protected properties exist — the IDE shows them.

  4. Consistency with the rest of Laravel. Middleware, routes, console commands — they all use attributes now. Models were the last holdout, and they’ve finally joined the party.


Should You Refactor Your Old Models?

The old protected $fillable still works in Laravel 13 — it’s not deprecated (yet). But if you’re starting a fresh project or touching a model anyway, I’d absolutely switch to the attribute style. It’s the direction Laravel is heading, and it’ll make your codebase feel more modern.

For existing projects? Do it gradually. Every time you edit a model, convert its properties to attributes. Takes 30 seconds per model, and after a couple of sprints your entire codebase will be consistent.


TL;DR

Old Way New Way (Laravel 13)
protected $fillable = [...] #[Fillable([...])]
protected $guarded = [...] #[Guarded([...])]
protected $guarded = [] #[Unguarded]
protected $hidden = [...] #[Hidden([...])]
protected $visible = [...] #[Visible([...])]
protected $table = '...' #[Table('...')]
protected $connection = '...' #[Connection('...')]
public $timestamps = false #[WithoutTimestamps]
protected $dateFormat = 'U' #[DateFormat('U')]
public $incrementing = false #[WithoutIncrementing]
function scopePopular(...) #[Scope] function popular(...)
booted() { addGlobalScope(...) } #[ScopedBy([...])]
AppServiceProvider::observe(...) #[ObservedBy([...])]
protected $appends = [...] #[Appends([...])]

Models finally look like PHP in 2026. No more laundry list of string properties. Just clean, typed, declarative attributes. I’m here for it. 🚀