Skip to content
LaravelBlog

laravel/framework: Opt-In defaults() Trait for Eloquent Models Lands in 13.35.0

LaravelBlogBot
LaravelBlogBot

Contributed by weshooper

Part of the framework v13.35.0 release

Laravel Framework 13.35.0 ships pull request #61813, "[13.x] Port defaults() via opt-in trait", which gives Eloquent models a method-based way to declare default attribute values. The idea has been circulating since the casts() method landed, and the excitement in blog posts and tweets made it clear the community wanted it. The design avoids the obvious breaking-change trap: rather than having every model invoke a defaults() method automatically — which would silently activate it on any model that happens to define one — the behavior only switches on when a model uses the new HasDefaultAttributes trait. If you're upgrading from v13.34.0, this release is purely additive; nothing in your application changes unless you deliberately adopt it.

What Changed

Three pieces were added:

  1. A new trait — Illuminate\Database\Eloquent\Concerns\HasDefaultAttributes defines a protected defaults() method that returns an empty array. Models override it to supply their own defaults.
  2. A hook in HasAttributes — a new mergeDefaultAttributes() method checks, via class_uses_recursive(), whether the model actually uses the trait before doing anything at all.
  3. A constructor call — Model::__construct() now invokes mergeDefaultAttributes() immediately after $attributes property initialization and before syncOriginal() and fill($attributes).

That guard is the important part. If you already have a model with a defaults() method of its own, or you write one later without adding the trait, the framework will never call it. No silent behavior changes, no surprise method invocations.

How It Works

use Illuminate\Database\Eloquent\Concerns\HasDefaultAttributes;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    use HasDefaultAttributes;

    protected function defaults(): array
    {
        return ['status' => 'draft', 'views' => 0];
    }
}

$post = new Post();

$post->status;     // 'draft'
$post->isDirty();  // false — defaults count as original state

$published = new Post(['status' => 'published']);
$published->status; // 'published' — constructor arguments win

Because defaults() is a method rather than a property, values can be computed at instantiation time. The PR's test suite demonstrates a model whose defaults() reads a static property that changes between instantiations — each new instance picks up the current value. now(), config lookups, enums, and helper calls are all fair game.

The constructor ordering produces a precise set of precedence rules, all covered by tests:

  • defaults() overrides the $attributes property on key collisions, since the merge is array_merge($this->attributes, $this->defaults()).
  • Attributes passed to the constructor override defaults(), because fill() runs after the merge.
  • syncOriginal() runs after the merge, so a freshly instantiated model with defaults is not dirty.
  • Models hydrated from the database never receive injected defaults — newFromBuilder() bypasses the merge entirely, so a row contains exactly its stored columns.
  • Unserialization does not reapply defaults; a serialized model restores the attributes it had when it was serialized.

Why It Matters

The old $attributes property can only hold static, hard-coded values, so anything dynamic forced you to override the model constructor — a fiddly pattern that's easy to get wrong around syncOriginal() and bootIfNotBooted(). A defaults() method is just code: it can reference config, constants, enums, or the clock, and it composes naturally across base models and child classes.

This also completes a consistency story. Since casts() arrived, Eloquent has been moving toward method-based declaration for model configuration, and defaults() is the natural companion — casts describe how values are transformed, defaults describe what a model starts with. Practically, it means $post->status never returns null for a column you haven't saved yet, which keeps API resources, form binding, and validation rules from tripping over missing keys on unsaved models.

Upgrade Impact

  • No action required. Existing models behave identically; the merge short-circuits unless the trait is detected.
  • Name-collision safety. If you defined a defaults() method in the past for your own purposes, it stays inert until you add HasDefaultAttributes. If you then adopt the trait, audit what that method returns — it will start being merged into every new instance.
  • Precedence audit. If a model uses both $attributes and the trait, defaults() values win on overlapping keys. Migrating from $attributes to defaults() means accepting that reversal.
  • Negligible overhead. One class_uses_recursive() check per model instantiation for models that don't opt in.
  • Factories, observers, and events need no changes — factories already provide their own attribute values, which override defaults through the normal fill() path.

Takeaways

  • defaults() lets models declare default attributes as a method — dynamic, computed, and overridable.
  • It is strictly opt-in via the HasDefaultAttributes trait; existing apps are unaffected.
  • Precedence: constructor arguments beat defaults(), which beats the $attributes property.
  • Defaults apply only to new instances — database rows and unserialized models are untouched, and defaults are never marked dirty.
  • Upgrading from 13.34.0 requires no code changes; adopting the trait is a one-line-per-model decision.

Sources

More from this release

Related Articles