framework icon indicating copy to clipboard operation
framework copied to clipboard

Model accessors always called when arraying a model

Open mattmcdev opened this issue 8 months ago • 13 comments

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

  1. Create a model with an accessor that dumps or logs when run, but don't include it in visible/appends.
  2. Call toArray on the model and observe that the accessor was run.

mattmcdev avatar Mar 18 '25 10:03 mattmcdev

Using attributes for composition from relations does not seem a good idea. We had the same issue and we solved it outside the model.

macropay-solutions avatar Mar 19 '25 06:03 macropay-solutions

Maybe this mr could solve your issue https://github.com/laravel/framework/pull/53655

macropay-solutions avatar Mar 19 '25 07:03 macropay-solutions

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(),
        );
    }
}

mattmcdev avatar Mar 19 '25 08:03 mattmcdev

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

macropay-solutions avatar Mar 19 '25 08:03 macropay-solutions

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.

macropay-solutions avatar Mar 19 '25 08:03 macropay-solutions

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 avatar Mar 19 '25 08:03 mattmcdev

@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.

macropay-solutions avatar Mar 19 '25 09:03 macropay-solutions

@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).

mattmcdev avatar Mar 19 '25 09:03 mattmcdev

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.

macropay-solutions avatar Mar 19 '25 09:03 macropay-solutions

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.

macropay-solutions avatar Mar 19 '25 09:03 macropay-solutions

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.

mattmcdev avatar Mar 19 '25 10:03 mattmcdev

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

macropay-solutions avatar Mar 19 '25 10:03 macropay-solutions

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!

github-actions[bot] avatar Mar 27 '25 00:03 github-actions[bot]