All posts

April 29, 2026

Laravel's PHP Attribute Workflow for Eloquent Models

ObservedBy, ScopedBy, UseFactory, Table, Scope: how PHP 8 native attributes are replacing model property boilerplate.


Laravel has been steadily adopting PHP 8 native attributes to reduce boilerplate and keep model configuration co-located with the model itself. Instead of wiring things up in service providers or relying on static property arrays, you declare intent directly on the class.

Laravel 13 expanded this significantly. Here's the full picture.

The Problem with the Old Approach

Before attributes, attaching behaviour to a model meant jumping between files. Observers registered in AppServiceProvider, global scopes buried in booted(), configuration scattered across a dozen protected properties.

// AppServiceProvider.php
public function boot(): void
{
    User::observe(UserObserver::class);
    User::addGlobalScope(new ActiveScope);
}

// User.php
class User extends Model
{
    protected $table = 'site_users';
    protected $connection = 'mysql';
    protected $primaryKey = 'uuid';
    protected $keyType = 'string';
    public $incrementing = false;
    public $timestamps = false;
    protected $fillable = ['name', 'email'];

    protected static function booted(): void
    {
        static::addGlobalScope(new ActiveScope);
    }

    public function scopePopular(Builder $query): void
    {
        $query->where('votes', '>', 100);
    }
}

This works, but reading the class gives you an incomplete picture. Context lives elsewhere.


#[ObservedBy] - Laravel 10

Attaches observers directly to the model. No service provider registration.

use Illuminate\Database\Eloquent\Attributes\ObservedBy;

#[ObservedBy([UserObserver::class, AuditObserver::class])]
class User extends Model {}

#[ScopedBy] - Laravel 11

Replaces the booted() global scope pattern.

use Illuminate\Database\Eloquent\Attributes\ScopedBy;

#[ScopedBy([ActiveScope::class, VerifiedScope::class])]
class User extends Model {}

The scope class still needs to implement Scope; the attribute just handles registration.

#[UseFactory] - Laravel 11

Explicitly ties a model to its factory instead of relying on naming conventions.

use Illuminate\Database\Eloquent\Attributes\UseFactory;
use Database\Factories\UserFactory;

#[UseFactory(UserFactory::class)]
class User extends Model {}

Useful when your factory lives outside the conventional namespace.


Laravel 13: Model Configuration as Attributes

This is where things get interesting. Laravel 13 replaced most of the protected property boilerplate with dedicated attributes.

#[Table]

Replaces $table, $primaryKey, $keyType, $incrementing, $timestamps, and $dateFormat, all in one place.

use Illuminate\Database\Eloquent\Attributes\Table;

#[Table('site_users')]
class User extends Model {}

You can also configure the primary key and its type:

#[Table('site_users', key: 'uuid', keyType: 'string', incrementing: false)]
class User extends Model {}

Disable timestamps or set a custom date format inline:

#[Table('events', timestamps: false, dateFormat: 'U')]
class Event extends Model {}

#[Connection]

Replaces the protected $connection property.

use Illuminate\Database\Eloquent\Attributes\Connection;

#[Connection('mysql')]
class Flight extends Model {}

Clean for multi-database applications where the connection is meaningful context for the model.

#[WithoutTimestamps] and #[WithoutIncrementing]

Focused alternatives when you only need one of those behaviours without specifying a full #[Table].

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

#[WithoutTimestamps]
class EventLog extends Model {}

#[WithoutIncrementing]
class ApiToken extends Model {}

#[DateFormat]

Replaces the protected $dateFormat property.

use Illuminate\Database\Eloquent\Attributes\DateFormat;

#[DateFormat('U')]
class Flight extends Model {}

#[Fillable] and #[Unguarded]

Replaces the protected $fillable array.

use Illuminate\Database\Eloquent\Attributes\Fillable;

#[Fillable(['name', 'email', 'password'])]
class User extends Model {}

#[Unguarded] removes mass assignment protection entirely, same as $guarded = []:

use Illuminate\Database\Eloquent\Attributes\Unguarded;

#[Unguarded]
class ImportRecord extends Model {}

#[Scope]

This one changes the local scope convention. Previously, scope methods had to be prefixed with scope and called without it:

// Old: method named scopePopular, called as ->popular()
public function scopePopular(Builder $query): void
{
    $query->where('votes', '>', 100);
}

With #[Scope], you name the method what it actually is:

use Illuminate\Database\Eloquent\Attributes\Scope;

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

Called identically (User::query()->popular()), but the method name no longer carries a framework-specific prefix.


What a Modern Model Looks Like

Putting it all together, a Laravel 13 model is largely self-documenting:

use Illuminate\Database\Eloquent\Attributes\{
    Table, Connection, Fillable, ObservedBy, ScopedBy, Scope
};

#[Table('site_users', key: 'uuid', keyType: 'string', incrementing: false)]
#[Connection('mysql')]
#[Fillable(['name', 'email'])]
#[ObservedBy(UserObserver::class)]
#[ScopedBy(ActiveScope::class)]
class User extends Model
{
    #[Scope]
    protected function verified(Builder $query): void
    {
        $query->whereNotNull('email_verified_at');
    }
}

Everything you need to understand the model's behaviour is on the class. No service provider lookups, no scattered property arrays.

This is the direction Symfony has been heading for years. A Doctrine entity in Symfony already looks like this:

#[ORM\Entity]
#[ORM\Table(name: 'users')]
class User
{
    #[ORM\Id]
    #[ORM\Column(type: 'uuid')]
    private string $id;

    #[ORM\Column(length: 180, unique: true)]
    private string $email;
}

Routing, validation, serialization: all driven by attributes. The configuration is on the class, not wired up somewhere else. Laravel is now converging on the same philosophy, just with Eloquent conventions instead of Doctrine's.

The direction is clear. Laravel is moving toward attributes as the primary configuration mechanism for Eloquent. If you're starting a new project on Laravel 13, this is the way to write models.