PHP Attributes on Laravel Models Are Here. Your Eloquent Classes Are About to Get a Lot Cleaner.

Laravel 13 ships 9 model attributes. Here’s every single one with before/after code, edge cases you’ll actually hit, and a practical migration strategy for existing apps.

Count the lines before your first method on any model you wrote last month. Four? Six? Eight? Every Laravel developer writes the same wall of protected $ declarations before touching any actual business logic.

Laravel 13 — released March 17, 2026 — changes that. PR #58578 introduces native PHP Attributes as a first-class alternative to class properties for Eloquent configuration. The feature lands in ~15 locations across the framework, but models are where most developers will feel it most.

This is the deep dive on model attributes specifically: every attribute, what it replaces, and the three things nobody is telling you about how they actually work.


First: What This Actually Is

PHP 8.0 introduced native attributes (#[SomeAttribute]) as a language feature. Laravel has used them sparingly for years — #[On] for Livewire, #[Computed] for Livewire properties, route attributes in some packages. But core framework configuration still relied on class properties.

PR #58578 is Laravel finally going all-in. The specific Laravel-specific attributes like #[Table], #[Fillable], and #[Connection] are new in Laravel 13 and require PHP 8.3+.

The migration path is intentional: none of this is breaking. Your existing property-based configuration still works. You can mix attributes and properties in the same codebase, and even in the same class. Laravel resolves both.


All 9 Model Attributes

1. #[Table] — Table name, primary key, and incrementing

Before:

class Invoice extends Model
{
    protected $table      = 'invoices';
    protected $primaryKey = 'invoice_id';
    protected $keyType    = 'string';
    public $incrementing  = false;
}

After:

use Illuminate\Database\Eloquent\Attributes\Table;

#[Table('invoices', key: 'invoice_id', keyType: 'string', incrementing: false)]
class Invoice extends Model {}

#[Table] packs four properties into one line. The key, keyType, and incrementing arguments are all optional — use only what you need:

#[Table('invoices')]           // just the table name
#[Table('invoices', key: 'uuid', keyType: 'string', incrementing: false)]  // full UUID config

2. #[Fillable] — Mass-assignable fields

Before:

protected $fillable = ['amount', 'status', 'user_id', 'description'];

After:

use Illuminate\Database\Eloquent\Attributes\Fillable;

#[Fillable('amount', 'status', 'user_id', 'description')]
class Invoice extends Model {}

Pass fields as individual arguments (variadic). For long field lists, the attribute reads more like a signature:

#[Fillable('name', 'email', 'phone', 'address', 'city', 'postcode', 'country')]
class Customer extends Model {}

3. #[Guarded] — Guard specific fields

Before:

protected $guarded = ['id', 'created_at', 'updated_at'];

After:

use Illuminate\Database\Eloquent\Attributes\Guarded;

#[Guarded('id', 'created_at', 'updated_at')]
class Invoice extends Model {}

4. #[Unguarded] — Disable mass assignment protection entirely

This one is attribute-only — there’s no equivalent class property for it. It’s a new capability that only exists in the attribute form:

use Illuminate\Database\Eloquent\Attributes\Unguarded;

#[Unguarded]
class InvoiceLineItem extends Model {}

Use with caution. The intended use case is internal-only models where you fully control the data being created — never for models that accept user input. The fact that Laravel shipped this as an attribute-only feature (rather than a property) hints at the direction new capabilities will take going forward.


5. #[Hidden] — Exclude from serialisation

Before:

protected $hidden = ['internal_notes', 'cost_price', 'supplier_id'];

After:

use Illuminate\Database\Eloquent\Attributes\Hidden;

#[Hidden('internal_notes', 'cost_price', 'supplier_id')]
class Invoice extends Model {}

6. #[Visible] — Whitelist serialised fields

Before:

protected $visible = ['id', 'amount', 'status', 'client_name', 'due_date'];

After:

use Illuminate\Database\Eloquent\Attributes\Visible;

#[Visible('id', 'amount', 'status', 'client_name', 'due_date')]
class Invoice extends Model {}

7. #[Appends] — Add computed attributes to serialisation

Before:

protected $appends = ['formatted_amount', 'days_overdue', 'client_display_name'];

After:

use Illuminate\Database\Eloquent\Attributes\Appends;

#[Appends('formatted_amount', 'days_overdue', 'client_display_name')]
class Invoice extends Model {}

Each string must match a get{Name}Attribute() accessor or a #[Attribute] property on the model.


8. #[Connection] — Specify the database connection

Before:

protected $connection = 'reporting';

After:

use Illuminate\Database\Eloquent\Attributes\Connection;

#[Connection('reporting')]
class MonthlyReport extends Model {}

Clean and immediately visible at the top of the class — useful for multi-database applications where knowing which database a model points to is important at a glance.


9. #[Touches] — Touch related model timestamps

Before:

protected $touches = ['order'];

After:

use Illuminate\Database\Eloquent\Attributes\Touches;

#[Touches(Order::class)]
class InvoiceLineItem extends Model {}

Note that #[Touches] accepts a class reference (Order::class) rather than a string relation name — a small but meaningful improvement over the property, which required 'order' as a string.


A Complete Before/After: The User Model

This is the transformation that will land in most codebases. Take a typical User model:

Before — Laravel 12:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use HasFactory, Notifiable;

    protected $table      = 'users';
    protected $primaryKey = 'user_id';
    protected $keyType    = 'string';
    public    $incrementing = false;
    protected $connection = 'mysql';

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

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

    protected $appends = ['full_name'];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'password'          => 'hashed',
    ];

    public function getFullNameAttribute(): string
    {
        return trim("{$this->first_name} {$this->last_name}");
    }

    // relationships and business logic start here...
}

After — Laravel 13:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Connection;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

#[Table('users', key: 'user_id', keyType: 'string', incrementing: false)]
#[Connection('mysql')]
#[Fillable('name', 'email', 'password')]
#[Hidden('password', 'remember_token')]
#[Appends('full_name')]
class User extends Authenticatable
{
    use HasFactory, Notifiable;

    protected $casts = [
        'email_verified_at' => 'datetime',
        'password'          => 'hashed',
    ];

    public function getFullNameAttribute(): string
    {
        return trim("{$this->first_name} {$this->last_name}");
    }

    // relationships and business logic — visible immediately
}

The class body shrinks from 15+ configuration lines to 7. Every method is visible without scrolling. The configuration sits above the class where it’s immediately scannable.

$casts stays as a property for now — there’s no #[Cast] attribute yet. Use the property form for casts.


Three Edge Cases You’ll Actually Hit

1. You can mix attributes and properties in the same class

Laravel resolves both. This works:

#[Table('invoices')]
#[Fillable('amount', 'status')]
class Invoice extends Model
{
    protected $hidden = ['internal_notes'];  // still works fine
}

This is intentional and important for gradual migration. You don’t need to convert an entire model in one pass. Migrate the properties you want to touch in this PR, leave the rest for later.

That said: pick one style per class for consistency. Mixing is technically fine but visually confusing for teammates reading the code.


2. Attributes on traits don’t propagate to the using class

This is the most important limitation. If you have a trait that sets model properties:

// ❌ This does NOT work
trait HasUuid
{
    #[Table(key: 'uuid', keyType: 'string', incrementing: false)]
    // This attribute applies to the TRAIT, not to the class using it
}

The PHP language spec doesn’t propagate class-level attributes from traits to the consuming class. If your application uses traits to share Eloquent configuration (a common pattern for UUID primary keys, multi-tenancy scoping, etc.), keep using properties in those traits:

// ✅ Keep using properties in traits
trait HasUuid
{
    protected $primaryKey = 'uuid';
    protected $keyType    = 'string';
    public    $incrementing = false;
}

Use attributes only on final model classes. Mixing attributes on the class and properties on traits works correctly — the trait properties are still resolved.


3. #[Unguarded] is the hint about Laravel’s direction

A comment in the PR noted that new features will likely land as attributes first going forward. #[Unguarded] being attribute-only (with no $guarded = [] equivalent that disables protection entirely) is the first concrete example of this. Keep an eye on new Eloquent features in 13.x patch releases — they’ll probably arrive as attributes before gaining a property equivalent, if they ever do.


Migration Strategy for Existing Apps

Don’t migrate everything at once. The non-breaking nature of the feature means you can be incremental.

Suggested approach:

  1. New models: Use attributes from day one after upgrading to Laravel 13. Every make:model you run going forward should use the new style.
  2. High-traffic models: Migrate when you’re already in the file making other changes. Don’t open a model just to migrate its properties.
  3. Skip traits: Leave any traits that set model configuration as properties. The trait limitation makes full migration impractical there.
  4. Keep $casts as a property: No #[Cast] attribute exists yet. Don’t fight the framework; use the property form.

The quick-win migration order for a model already open in your editor:

  • $table#[Table] (biggest visual improvement)
  • $fillable + $hidden#[Fillable] + #[Hidden]
  • $connection#[Connection] (only if non-default)
  • $appends#[Appends]
  • $touches#[Touches]

Leave $casts and anything set by traits as-is.


Why This Matters Beyond Aesthetics

The argument for PHP Attributes isn’t just visual cleanliness — though 6–10 fewer lines per model across 20+ models is genuinely meaningful.

The deeper point is declaration versus configuration. Class properties are evaluated at runtime as part of the class instance. Attributes are metadata attached to the class definition itself — readable by static analysis tools, IDEs, and the runtime alike without instantiating the class.

That means IDEs can index your model configuration without executing PHP. Static analysis tools can read #[Table('invoices')] and know, without running your application, that this model maps to the invoices table. As the Laravel ecosystem builds better tooling around attributes, models that use them become more statically analysable.

It’s a small step today. It’s an important architectural direction.


Follow me for daily deep-dives on Laravel, PHP, Vue.js, and AI integrations. New article every day.

Leave a Reply

Your email address will not be published. Required fields are marked *