Model accessors always called when arraying a model
Laravel Version
12.2
PHP Version
8.4.1
Database Driver & Version
No response
Description
All model accessors are called when calling toArray on a model, even if they aren't included in the array.
Consider the following:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Foo extends Model
{
protected $visible = [
'id',
'name',
];
protected function location(): Attribute
{
return Attribute::make(
get: fn () => 'Paris',
);
}
}
When calling toArray getMutatedAttributes is always run and then filtered down to just the arrayable attributes.
Why does this matter ?
Because with a more complex Attribute accessor that relies on a relation, you introduce potential eager-loading issues when using API resource collections , e.g.
protected function timezone(): Attribute
{
return Attribute::make(
get: fn () => $this->location->timezone,
);
}
Steps To Reproduce
- Create a model with an accessor that dumps or logs when run, but don't include it in
visible/appends. - Call
toArrayon the model and observe that the accessor was run.
Using attributes for composition from relations does not seem a good idea. We had the same issue and we solved it outside the model.
Maybe this mr could solve your issue https://github.com/laravel/framework/pull/53655
Maybe this mr could solve your issue #53655
It probably would for the relation example.
But there's other reasons needlessly running your accessors isn't ideal.
Consider this example, running this when it's not needed adds unnecessary latency with a call into Stripe.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Laravel\Cashier\Billable;
class User extends Model
{
use Billable;
protected function totalPaymentMethods(): Attribute
{
return Attribute::make(
get: fn () => $this->paymentMethods()->count(),
);
}
}
The count example can be solved with function call
->withCount('payment_methods') or ->withCount('paymentMethods')when querying and you can also alias the resulting key as total_payment_methods , default being payment_methods_count or paymentMethods_count
But there's other reasons needlessly running your accessors isn't ideal.
If the code can be improved without side effects, disregarding the above 2 relations examples, then it is a good idea.
The count example can be solved with function call
->withCount('payment_methods')or->withCount('paymentMethods')when querying and you can also alias the resulting key as total_payment_methods , default being payment_methods_count or paymentMethods_count
paymentMethods isn't a relation - it's a call to the Stripe API.
@mattmcdev we would not put a http call in an attribute accessor. We would use appends for that and append it only on a get by id call for example when collections of models are not involved.
@mattmcdev we would not put a http call in an attribute accessor. We would use appends for that and append it only on a get by id call for example when collections of models are not involved.
Do you mean this ? https://laravel.com/docs/12.x/eloquent-serialization#appending-at-run-time
Because I would expect calling $user->append('total_payment_methods') to make a call to totalPaymentMethods, and $user->toArray() not to (assuming I hadn't added it to the global $appends property).
toArray will execute also the appends no matter how they are appended. The idea is that in a collection, you can't avoid n+1 problem for http calls when using appends or attributes.
// Here we will grab all of the appended, calculated attributes to this model
// as these attributes are not really in the attributes array, but are run
// when we need to array or JSON the model for convenience to the coder.
foreach ($this->getArrayableAppends() as $key) {
$attributes[$key] = $this->mutateAttributeForArray($key, null);
}
Eager loading the results from http calls could be a solution coupled with async curl_multi_init calls if you can't fetch all in one http call.
You can also keep a local table as relation with that data and increment it with each payment if needed and you schedule a cron that updates the data for you. In that way you avoid the n+1 issue.
Totally understand there are workarounds to all the things you might do in an accessor that mitigate the issue of it being called when it isn't needed.
But the root issue is that accessors are called when there's no need for them to be called which is inefficient - with a varying level of inefficiency depending on what you're doing in the accessor.
Have a look at this similar issue New Custom Casting For Eloquent Models Has Excessive set() calls
But the root issue is that accessors are called when there's no need for them to be called which is inefficient - with a varying level of inefficiency depending on what you're doing in the accessor.
We agree
Thank you for reporting this issue!
As Laravel is an open source project, we rely on the community to help us diagnose and fix issues as it is not possible to research and fix every issue reported to us via GitHub.
If possible, please make a pull request fixing the issue you have described, along with corresponding tests. All pull requests are promptly reviewed by the Laravel team.
Thank you!