Laravel Json Api Eloquent queries vs $visible property

Laravel Json Api Eloquent queries vs $visible property

In this article we approach the usability of $visible and $hidden properties, and their impact on the usage of our resources. vs a surgical approach.

If you are building a JSONAPI, certainly you will be showing data, however when you cast the model fields, often you dont cast all the fields, lets use the example of users, and head to the User Model on a fresh Laravel 8 installation.

Certainly you will find this property. to select fields you want to hide. by default Laravel hides 'password' and 'remember_token'

We can either add more fields

/**
 * The attributes that should be hidden for serialization.
 *
 * @var array
 */
protected $hidden = [
    'password',
    'remember_token',
];

Or furthermore we can explicitly override the $hidden property using the $visible property, in which only the ones we show will be shown.

/**
 * The attributes that should be cast.
 *
 * @var array
 */
protected $visible = ['id', 'name', 'email', 'email_verified_at'];

This looks like what we want right? Not really, there could be a massive impact on our resources and speed when using this naive approach. Lets instead write some code and see a different approach.

Routes

Route::prefix('v1')->group(function(){
    Route::apiResource('users', UsersController::class);
});

Test

To test our example lets just write a test. instead of the browser. This would assert we can see the visible fields, and also we cannot see the hidden ones, Its just for the purpose of our blog article

class ExampleTest extends TestCase
{

    use RefreshDatabase;

    /** @test */
    public function it_does_fetch_all_users()
    {
                        $users = User::factory()->count(100)->create();

                        $response = $this->get('/api/v1/users');

            $user = User::all();
            $this->assertCount(100, $users);

            $response->assertSeeText($users->random()->id);
            $response->assertSeeText($users->random()->name);
            $response->assertSeeText($users->random()->email);
            $this->assertInstanceOf(Carbon::class, $users->random()->email_verified_at);

                        $response->assertDontSeeText($users->random()->password);
            $response->assertDontSeeText($users->random()->remember_token);
        }

Controller

Lets create a UsersController and query all the users. with our naive approach.

class UsersController extends Controller
{
/**
 * Display a listing of the resource.
 *
 * @return \Illuminate\Http\Response
 */
public function index(Request $request)
{
        $users = User::get();

        return $users;
}

So everthing looks just fine, however we are quering fields we dont see nor we need, which uses our memory so resources and speed. Lets just die dump, and debug.

Die and dump the collection of a random user will output the following

We debug using favorite Laravel debugging function, dd('anything you want to debug'), to fetch one of the users randomly and check the attributes fetched, here comes our result.

.array:8 [
  "id" => "72"
  "name" => "Mr. Kieran Schaden"
  "email" => "zetta88@example.com"
  "email_verified_at" => "2021-10-14 19:12:29"
  "password" => "$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi"
  "remember_token" => "8cNgnS0CYe"
  "created_at" => "2021-10-14 19:12:29"
  "updated_at" => "2021-10-14 19:12:29"
]

The problem

This fetches the email_verified_at field which we set to visible however our bigger problem is not what we see but what we actually query.

Lets check instead what we query and assert the wrong path first.

And to assert this is the case we will add some tests.

class ExampleTest extends TestCase
{

    use RefreshDatabase;


    /** @test */
    public function it_does_fetch_all_users()
    {
                        $users = User::factory()->count(100)->create();

                        $response = $this->get('/api/v1/users');

            $user = User::all();
            $this->assertCount(100, $users);

            $response->assertSeeText($users->random()->id);
            $response->assertSeeText($users->random()->name);
            $response->assertSeeText($users->random()->email);
            $this->assertInstanceOf(Carbon::class, $users->random()->email_verified_at);

            $this->assertArrayHasKey('id', $users->random()->getAttributes() );
            $this->assertArrayHasKey('name', $users->random()->getAttributes() );
            $this->assertArrayHasKey('email', $users->random()->getAttributes() );
            $this->assertArrayHasKey('email_verified_at', $users->random()->getAttributes() );
            $this->assertArrayHasKey('password', $users->random()->getAttributes() );
            $this->assertArrayHasKey('remember_token', $users->random()->getAttributes() );
            $this->assertArrayHasKey('created_at', $users->random()->getAttributes() );
            $this->assertArrayHasKey('updated_at', $users->random()->getAttributes() );
        }

So we are not really in the need for the "password" or "created_at" and "updated_at", in fact all we need are the 3 fields, id, email, name.

Therefor the visible is interesting for us in how we show the data, but is not helping in querying them. this will eventually result in quering a ridiculous amount of data we wont show and wont need. to fix that:

The solution

class ExampleTest extends TestCase
{

    use RefreshDatabase;

    /** @test */
    public function it_does_fetch_all_users()
    {
                        $users = User::factory()->count(100)->create();

                        $response = $this->get('/api/v1/users');

            $user = User::all();
            $this->assertCount(100, $users);

            $response->assertSeeText($users->random()->id);
            $response->assertSeeText($users->random()->name);
            $response->assertSeeText($users->random()->email);
            $this->assertInstanceOf(Carbon::class, $users->random()->email_verified_at);

            $this->assertArrayHasKey('id', $users->random()->getAttributes() );
            $this->assertArrayHasKey('name', $users->random()->getAttributes() );
            $this->assertArrayHasKey('email', $users->random()->getAttributes() );
            $this->assertArrayHasKey('email_verified_at', $users->random()->getAttributes() );
            $this->assertArrayHasKey('password', $users->random()->getAttributes() );
            $this->assertArrayHasKey('remember_token', $users->random()->getAttributes() );
            $this->assertArrayHasKey('created_at', $users->random()->getAttributes() );
            $this->assertArrayHasKey('updated_at', $users->random()->getAttributes() );

                        $users = User::query()->select('id','email','name')->get();

            $this->assertArrayNotHasKey('password', $users->random()->getAttributes() );
            $this->assertArrayNotHasKey('remember_token', $users->random()->getAttributes() );
            $this->assertArrayNotHasKey('created_at', $users->random()->getAttributes() );
            $this->assertArrayNotHasKey('updated_at', $users->random()->getAttributes() );
    }
}

We use the select method, on the users Model, this will only fetch the specified data, our tests asserts that now.

But we can even refactor.

Final solution

However in most cases for our api we would never show the password or remember token and other fields right? So it makes a sen to never qurry them them using a global scope, we will anyways be able to make a query without global scopes whenever we need that. This will eventually give us a speed bump, for the convenience to strictly query fields we need. which is safer and more meaningful.

// User Modek Class
protected static function booted()
{
    static::addGlobalScope('JsonAttributes', function (Builder $builder) {
        $builder->select('id', 'name', 'email');
    });
}

Our final test methods might look like this.


/** @test */
public function global_scope_name_on_the_user_model(): void
{
        $user = new User();

        $this->assertArrayHasKey('JsonAttributes', $user->getGlobalScopes());
}

/** @test */
public function it_does_fetch_all_users_with_defined_attributes(): void
{
    $users = User::factory()->count(100)->create();

    $response = $this->get('/api/v1/users');

    $user = User::get();
    $this->assertCount(100, $users);

    $response->assertSeeText($users->random()->id);
    $response->assertSeeText($users->random()->name);
    $response->assertSeeText($users->random()->email);
        $this->assertInstanceOf(Carbon::class, $users->random()->email_verified_at);

        $response->assertDontSeeText($users->random()->password);
    $response->assertDontSeeText($users->random()->remember_token);
}

/** @test */
public function it_does_assert_global_scope_works_for_users_model(): void
{
    $users = User::factory()->count(100)->create();
    $users = User::withoutGlobalScopes()->get();

    $this->assertArrayHasKey('id', $users->random()->getAttributes() );
    $this->assertArrayHasKey('name', $users->random()->getAttributes() );
    $this->assertArrayHasKey('email', $users->random()->getAttributes() );
    $this->assertArrayHasKey('email_verified_at', $users->random()->getAttributes() );
    $this->assertArrayHasKey('password', $users->random()->getAttributes() );
    $this->assertArrayHasKey('remember_token', $users->random()->getAttributes() );
    $this->assertArrayHasKey('created_at', $users->random()->getAttributes() );
    $this->assertArrayHasKey('updated_at', $users->random()->getAttributes() );

    $users = User::get();

    $this->assertArrayNotHasKey('password', $users->random()->getAttributes() );
    $this->assertArrayNotHasKey('remember_token', $users->random()->getAttributes() );
    $this->assertArrayNotHasKey('created_at', $users->random()->getAttributes() );
    $this->assertArrayNotHasKey('updated_at', $users->random()->getAttributes() );
    $this->assertArrayNotHasKey('email_verified_at', $users->random()->getAttributes() );
}