From #[ObservedBy] in 10.44 to 50+ attributes in Laravel 13: the ones worth knowing past the obvious ones.

The Model That Tells You Nothing Until You Know Where to Look
You open a new model. It extends Model, has a few relationships and a cast or two. To understand what it actually does, you open AppServiceProvider to find the observer registration. You scan booted() for global scopes. You grep for the model name to find where the policy gets registered in AuthServiceProvider. You check a different file for the custom query builder that overrides newEloquentBuilder().
🚀 Land Your Dream Tech Job in Weeks
💰 $50–$120/hr | Multiple Roles Open
Frontend • Backend • Full Stack • AI/ML • DevOps
👉 Apply Now & Get Hired Faster

The information is all there. It is just not there, on the class.
PHP 8 attributes change this. Laravel has been adopting them since 10.44 and went fully committed in Laravel 13, which added 36 new attribute classes in a single release, bringing the total past 50. Most developers pick up #[ObservedBy] and #[Fillable], consider the feature explored, and move on. This is where the more useful ones tend to get left behind.
How We Got Here: The Version Timeline
Laravel 10.44 (February 2024) introduced the first two Eloquent-specific attributes: #[ObservedBy] for model observers and #[ScopedBy] for global scopes. Both were contributed by Eliezer Margareten and landed in the same patch release. Observers that previously required User::observe(UserObserver::class) in a service provider could now be declared directly on the model.
Laravel 11 and 12 continued adding model-level attributes: #[UseFactory] to link models to factories without relying on naming conventions, and #[UseEloquentBuilder] landing specifically in Laravel 12.19 to bind custom query builder classes declaratively.
Laravel 13 is where the framework committed fully. Thirty-six new attribute classes in a single release, covering models, jobs, console commands, controllers, form requests, API resources, factories, and testing infrastructure. Attributes are not replacing property-based configuration; they are an additive alternative. Every protected $fillable still works. The attributes are not breaking changes.
The One Worth Using Before Anything Else: #[UseEloquentBuilder]
Custom Eloquent query builders are genuinely useful. They let you move complex scope methods out of the model, make query logic testable in isolation and give large models room to breathe. The problem has always been the registration: you override newEloquentBuilder() on every model that uses the builder.
// Old way — method override on every model
class Post extends Model
{
public function newEloquentBuilder($query): PostQueryBuilder
{
return new PostQueryBuilder($query);
}
}#[UseEloquentBuilder] was added in Laravel 12.19. The method override is gone. The builder class is declared on the class itself, visible the moment you open the file.
use Illuminate\Database\Eloquent\Attributes\UseEloquentBuilder;
#[UseEloquentBuilder(PostQueryBuilder::class)]
class Post extends Model
{
// That's it. No method override needed.
}Your PostQueryBuilder extends Illuminate\Database\Eloquent\Builder exactly as before. Nothing about the builder itself changes; only how it gets associated with the model changes. This is the attribute most teams should reach for first if they already use custom builders, because the DX improvement is immediate with zero risk.
The One That Moves Policy Registration Off the Service Provider: #[UsePolicy]
Gates and policies have two registration points: the $policies array in AuthServiceProvider (or the auto-discovery policy in Laravel 10+), and manual Gate::policy() calls. When a model and its policy live in the same domain, registering the relationship in a central service provider creates an invisible connection.
#[UsePolicy] moves that declaration onto the model itself.
// Old way — in AuthServiceProvider
protected $policies = [
Post::class => PostPolicy::class,
];
// New way - on the model
use Illuminate\Database\Eloquent\Attributes\UsePolicy;
#[UsePolicy(PostPolicy::class)]
class Post extends Model {}Open Post.php and you see what policy governs it. Open PostPolicy.php and the connection is visible from the model side. For teams that work domain-by-domain rather than layer-by-layer, this locality has real value. The policy class itself does not change at all.
The One That Replaces the newCollection Override: #[CollectedBy]
Custom Eloquent collection classes give you methods that operate across a collection of model instances. The traditional way to attach one to a model is overriding newCollection():
// Old way
class Post extends Model
{
public function newCollection(array $models = []): PostCollection
{
return new PostCollection($models);
}
}This is one of those methods that always requires a comment explaining why it exists.
// New way
use Illuminate\Database\Eloquent\Attributes\CollectedBy;
#[CollectedBy(PostCollection::class)]
class Post extends Model {}PostCollection still extends Illuminate\Database\Eloquent\Collection. Everything about how you use it is identical. The difference is that Post.php now tells you this model uses a custom collection without requiring you to know what newCollection() does or remember it needs to be overridden.
The One That Changes Local Scope Naming: #[Scope]
Local scopes before Laravel 13 follow a naming convention: the method is called scopePublished, the caller uses ->published(). The scope prefix is invisible at the call site but present in the method name. This works, but the method name has always looked slightly wrong: a framework artefact rather than actual domain language.
// Old way — method name carries the prefix
public function scopePublished(Builder $query): void
{
$query->whereNotNull('published_at');
}
// Called as:
Post::query()->published();#[Scope] from Laravel 13 lets you name the method what it actually is. The call site is identical.
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
class Post extends Model
{
#[Scope]
protected function published(Builder $query): void
{
$query->whereNotNull('published_at');
}
}
// Called identically:
Post::query()->published();The method name now reads as domain language. A developer reading the model sees published, featured, draftedBy rather than scopePublished, scopeFeatured, scopeDraftedBy. For models with many scopes this matters more than it initially seems.
The One That Untangles booted(): #[Boot] and #[Initialize]
Everything that needs to run when a model class loads gets pushed into booted() as a static method, or initializeTraitName() for traits. As models grow, booted() becomes a grab-bag: event registration, global scope attachment, default-value logic. The method name tells you nothing about what it contains.
#[Boot] and #[Initialize] let you split this into named methods.
use Illuminate\Database\Eloquent\Attributes\Boot;
use Illuminate\Database\Eloquent\Attributes\Initialize;
class Post extends Model
{
#[Boot]
public static function registerSlugGeneration(): void
{
static::creating(function (Post $post) {
$post->slug ??= Str::slug($post->title);
});
}
#[Boot]
public static function registerStatusEvents(): void
{
static::updating(function (Post $post) {
if ($post->isDirty('status')) {
event(new PostStatusChanged($post));
}
});
}
#[Initialize]
public function setDefaultValues(): void
{
$this->attributes['status'] ??= 'draft';
$this->attributes['visibility'] ??= 'private';
}
}Each method has a name that explains its purpose. You can add, remove or reorganize these without touching other boot logic. The model’s lifecycle is readable without reading through a single large method to understand what it registers.
The One That Almost Nobody Knows Exists: Container Attributes
This category lives outside models entirely. Container attributes go on constructor and method parameters, and they instruct Laravel’s service container on exactly what to inject.
use Illuminate\Container\Attributes\Config;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Container\Attributes\DB;
use Illuminate\Container\Attributes\Log;
use Illuminate\Container\Attributes\Cache;
use Illuminate\Container\Attributes\Storage;
class PaymentService
{
public function __construct(
#[Config('services.stripe.secret')] private string $stripeSecret,
#[Config('services.stripe.webhook_secret')] private string $webhookSecret,
#[Log('payments')] private LoggerInterface $logger,
#[Cache('redis')] private Repository $cache,
) {}
}
class DashboardController extends Controller
{
public function index(
#[CurrentUser] User $user,
#[DB('analytics')] Connection $analyticsDb,
#[Storage('s3')] Filesystem $storage,
) {
// $user, $analyticsDb, $storage all injected by the container
}
}The old approach for config injection is a manual config() call inside the constructor, or a dedicated binding in a service provider. For logger channels and cache stores, the typical pattern is either a factory method or a service provider binding that returns the right instance for the key.
Container attributes remove the binding entirely. The constructor parameter declares what it needs and the container resolves it. A PaymentService that needs Stripe credentials now carries that dependency as part of its type signature, visible to any developer reading the class. No service provider lookup required.
There is also a practical testing benefit. Because the dependencies are declared at the parameter level, swapping them in tests is straightforward: bind a different config value or a fake logger in the test’s service container setup and the class picks it up with no additional plumbing. The attribute is a declaration, not a hard-coded call, so it participates in the container’s standard resolution logic the same way any other binding does.
What a Fully Attribute-Driven Model Looks Like
Pull all of these together and a Laravel 13 model becomes largely self-documenting:
use Illuminate\Database\Eloquent\Attributes\{
Table, Connection, Fillable, Hidden,
ObservedBy, ScopedBy, UseEloquentBuilder,
UsePolicy, CollectedBy, Boot, Scope
};
#[Table('blog_posts', key: 'uuid', keyType: 'string', incrementing: false)]
#[Connection('mysql')]
#[Fillable(['title', 'body', 'status', 'author_id'])]
#[Hidden(['internal_notes', 'review_flags'])]
#[ObservedBy(PostObserver::class)]
#[ScopedBy(ActiveScope::class)]
#[UseEloquentBuilder(PostQueryBuilder::class)]
#[UsePolicy(PostPolicy::class)]
#[CollectedBy(PostCollection::class)]
class Post extends Model
{
#[Boot]
public static function registerSlugGeneration(): void
{
static::creating(fn (Post $post) => $post->slug ??= Str::slug($post->title));
}
#[Scope]
protected function published(Builder $query): void
{
$query->whereNotNull('published_at');
}
#[Scope]
protected function featuredIn(Builder $query, string $section): void
{
$query->where('featured_section', $section);
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
}
}This model tells you: its table name and primary key configuration, which database connection it uses, what fields are mass-assignable, what gets hidden from serialization, which observer watches its lifecycle, which global scope filters it, which query builder adds domain-specific queries, which policy authorizes access to it, which collection class wraps result sets and how slugs get generated. None of this requires you to open another file.
When to Actually Adopt These
All of these attributes are additive. There is no cost to not using them and no pressure to migrate existing models. The case for adoption depends on your situation:
Adopt #[UseEloquentBuilder] first if you already use custom query builders. The migration is a method deletion, and every model gets clarity immediately.
Adopt #[UsePolicy] in greenfield work or when adding policies to models that don't have one yet. Retroactive migration has low priority; the AuthServiceProvider approach works fine.
For #[CollectedBy], #[Boot], and #[Scope], prioritise models where the existing approach is already confusing. A model whose booted() does six things is a better candidate than one whose booted() registers a single event.
Container attributes earn their keep when you find yourself writing service provider bindings solely to inject a specific config value, logger channel, or cache store. These are the clearest wins: one attribute on a constructor parameter replaces a binding and a manual config() call, and the class becomes easier to read and test in one move.
None of these require adopting the others. You can use #[Scope] without adopting #[Table]. You can use #[UseEloquentBuilder] without touching model-level configuration attributes at all. The framework's property-based configuration is going nowhere. Attributes are the option you reach for when co-location and readability matter more than convention.
The direction Laravel is heading has been clear since 10.x. Symfony’s Doctrine entities have worked this way for years. The configuration lives on the class, not wired up elsewhere. Laravel is now converging on the same philosophy, using Eloquent conventions instead of Doctrine’s and PHP attributes as the bridge.
Thank you for being a part of the community
Before you go:

👉 Be sure to clap and follow the writer ️👏️️
👉 CodeToDeploy Tech Community is live on Discord — Join now!
Disclosure: This post includes affiliate and partnership links.


