Laravel 14 Eloquent defaults(): Model Attribute Defaults That Run Code
Laravel 14 adds a defaults() method to Eloquent models, so attribute defaults can use config, feature flags, and dates. Here is how it behaves.
If you have ever tried to put now() or config('something') inside an Eloquent model’s $attributes array, you already know how that story ends. PHP only allows constant expressions in a property’s default value, so the code will not even parse. The usual workaround is overriding the constructor, which works but feels like a lot of ceremony for “give new subscriptions a 14-day trial.”
Laravel 14 has a cleaner answer. According to Laravel News, it adds a defaults() method to Eloquent models that returns the same kind of array as $attributes, but from code that runs when the model is created.
One caveat up front. The feature was contributed by Jack Bayliss in pull request #61688 and, as of the Laravel News article published October 1, 2026, it is merged into the master branch and can still change before Laravel 14 is released. Treat everything below as a preview, not a final API.
The problem with $attributes
Say you run a subscription product for PHP Architect readers. New subscriptions get a 14-day trial, or 30 days when an extended-trials feature flag is on. With the $attributes property you cannot ask the flag a question, so the constructor takes over:
use Laravel\Pennant\Feature;
class Subscription extends Model
{
protected $attributes = [
'cancelled' => false,
'trial_days' => 14,
];
public function __construct(array $attributes = [])
{
if (Feature::active('extended-trials')) {
$this->attributes['trial_days'] = 30;
}
parent::__construct($attributes);
}
}
This is the same example Laravel News uses, and it is a good one because it is realistic. Overriding __construct() on a model is easy to get subtly wrong. Forget to call the parent, or call it in the wrong order, and you are debugging mass assignment at 4 p.m. on a Friday.
The Laravel 14 version
With defaults(), the same logic is a plain method:
class Subscription extends Model
{
protected function defaults(): array
{
return [
'cancelled' => false,
'trial_days' => Feature::active('extended-trials') ? 30 : 14,
'expires_at' => now()
->addDays(config('billing.grace_period'))
->toDateTimeString(),
];
}
}
$subscription = new Subscription;
$subscription->trial_days; // 30 when the flag is active, 14 otherwise
No constructor, no parent call, and the config value and the date calculation sit right next to the other defaults where you would look for them.
There is a nice bit of history here. Laravel 11 made a similar move for casts, when the application skeleton switched from the $casts property to a casts() method (Laravel News). defaults() gives $attributes the same treatment.
Which value wins
The model constructor calls defaults() and merges the result into $attributes. Per the article, the order is:
- Values from the
$attributesproperty are set first. defaults()is merged over them, so it wins when both define the same key.- Attributes you pass to
new,create(), orfill()are applied last, so they beat both.
That last rule is what you want in practice. A caller can still override the default:
$subscription = Subscription::create(['trial_days' => 60]);
$subscription->trial_days; // 60
Defaults are merged before the model syncs its original attributes, so they do not show up as dirty changes. They are still written to the database when the model is inserted, exactly like $attributes.
Values are raw, not cast
This is the part most likely to bite you. Like $attributes, the values from defaults() are raw. They do not pass through casts or mutators, so return the value the way it would be stored in the database:
protected function defaults(): array
{
return [
// A column cast to `array` expects a JSON string
'settings' => json_encode(['theme' => 'light']),
// A date column
'expires_at' => now()->addDays(30)->toDateTimeString(),
];
}
If you return a PHP array for a column cast to array or json, the failure does not happen when you set it. It happens when the attribute is read, because the cast expects a JSON string. That is a confusing place for an error to surface, so remember the rule: raw database values only.
It runs on every instance, including loaded rows
The method runs every time a model instance is constructed. That includes models loaded from the database. Eloquent builds each retrieved row with a new model instance, so defaults() runs once per row, and then the row’s real values replace the defaults.
Here is my own reading of what that means, so take it as an inference rather than something the article states: anything slow or side-effecting inside defaults() gets multiplied by the number of rows you hydrate. A Feature::active() check is probably fine if your feature flag driver caches. A database query or an HTTP call is not. If you load 5,000 subscriptions in a report, you do not want 5,000 trips to anywhere.
A few habits I plan to follow:
- Keep
defaults()cheap and free of side effects. - Resolve expensive lookups once, outside the model, and read them from config.
- Never write to the database or dispatch events from this method.
- Benchmark hydration of a large result set before and after if you add anything non-trivial.
Upgrading: check for name collisions
Laravel 14 targets this feature at a new major version because an existing model might already have a method named defaults(). In Laravel 14, the constructor calls that method and merges whatever it returns into the attributes. If yours returns something else entirely, you will get strange behavior, or an error, the first time a model is instantiated.
The article suggests searching your models before upgrading:
grep -rn "function defaults" app/Models
Rename any method that is not meant to return default attribute values. If you maintain packages that ship models, run the same search there too, and add it to your upgrade checklist next to the usual dependency audit.
Should you use it?
For new Laravel 14 projects, I think the answer is yes whenever a default depends on runtime state: config, a feature flag, the current time, or the authenticated tenant. Fixed values like 'cancelled' => false can stay in $attributes where they have always lived, and mixing both is fine because of the merge order.
For existing projects, wait. The method is on master, not in a tagged release, and the maintainers have said it can change. Until Laravel 14 ships, the constructor approach still works and is not going anywhere. When the release lands, the migration is mechanical: move the logic out of __construct() into defaults(), double check that every returned value is in its raw database form, and run your test suite.
If you want a quick safety net, write one test per model that uses defaults():
it('applies the extended trial default when the flag is on', function () {
Feature::activate('extended-trials');
expect((new Subscription)->trial_days)->toBe(30);
});
it('lets callers override the default', function () {
expect(Subscription::make(['trial_days' => 60])->trial_days)->toBe(60);
});
Tests like these are cheap, and they will catch the raw-value mistake and any future change to the merge order before your users do.
Sources
- Laravel 14 Adds a defaults() Method to Eloquent Models, Paul Redmond, Laravel News, October 1, 2026
- laravel/framework pull request #61688, Jack Bayliss
- Model Casts are moving to methods in Laravel 11, Laravel News
- What We Know About Laravel 14, Laravel News