DEV Community

Cover image for Laravel Eloquent Serialization: Hidden, Appends, Dates
devTalk
devTalk

Posted on Originally published at dev-talk.com

Laravel Eloquent Serialization: Hidden, Appends, Dates

The easiest Laravel API is one line: return User::all();. Eloquent turns the models into JSON for you. That convenience is also where leaks and slow endpoints start: a password hash in the response, a date in a format your frontend does not expect, or an "extra" computed field that quietly fires a query per row. This article walks through how Eloquent serialization works in Laravel 13, using the official documentation as the map and a real database to test the parts that surprise people.

The basics: toArray() and toJson()

$user = User::with('roles')->first();

$user->toArray();             // attributes + loaded relationships, recursively
$user->attributesToArray();   // attributes only, no relationships
$user->toJson();              // JSON string
$user->toJson(JSON_PRETTY_PRINT);

(string) $user;               // same as toJson()
Enter fullscreen mode Exit fullscreen mode

I confirmed that casting a model to a string produced exactly the same text as toJson(). According to the docs, models and collections returned from a route or controller are converted to JSON automatically. I did not run a real route, so that part is from the documentation.

One detail that trips people up: a loaded relationship appears in the output under a snake_case key even though the method is camelCase. My blogPosts() relationship showed up as blog_posts.

Hide sensitive attributes

In Laravel 13 the documentation uses the Hidden attribute (the older $hidden property is the same idea):

use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Model;

#[Hidden(['password', 'remember_token'])]
class User extends Model
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Test result: toArray() returned id, name, email, birthday, created_at, updated_at and no password or token. Two important limits, both confirmed:

  • Hidden only affects serialization. $user->password still returned the hash, and getAttributes() still contained it. It stops the leak in JSON output, nothing more.
  • You can hide a relationship by name too. Hiding blogPosts removed blog_posts from the output.

Or use an allow list with Visible

If you would rather list what is allowed, use #[Visible([...])]. With #[Visible(['name', 'email'])] the output was exactly name and email. An allow list is safer for models that grow over time, because a new column added later stays out of the API until you add it on purpose.

Change visibility at runtime, and the gotcha

$user->makeVisible('password')->toArray();
$user->makeHidden('email')->toArray();
$user->mergeHidden(['name', 'email'])->toArray();
$user->setHidden(['email'])->toArray();   // replaces the whole hidden list
Enter fullscreen mode Exit fullscreen mode

The docs describe these as changes for "one instance", and that is the catch: they modify the model object itself. I called makeVisible('password') and then called toArray() on the same object again, and the password was still there. A fresh query returned a clean model with the password hidden. So never reuse a model after exposing a field for one response, for example by caching it or passing it to other code.

Also note that setHidden(['email']) replaces the list, so the password and token came back, and only the email was hidden. Use mergeHidden() when you want to add to the existing list.

Add computed values with appends

A value that has no column needs an accessor first. Then add it to the output with the Appends attribute, using the snake_case name:

use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Casts\Attribute;

#[Appends(['is_admin'])]
class User extends Model
{
    protected function isAdmin(): Attribute
    {
        return Attribute::get(fn () => 'yes');
    }
}
Enter fullscreen mode Exit fullscreen mode

The output contained is_admin: "yes". For a single response you can use $user->append('is_admin') (also worked), and $user->withoutAppends() removed the field again. The docs add that appended values still respect your Hidden and Visible settings. In my test the allow-listed model, which listed only name and email, did not show an appended is_admin either.

The hidden cost: appends can create N+1 queries

An appended accessor runs for every model in the result. If it touches the database, you pay for it once per row. I appended a post_count accessor that called $this->blogPosts()->count() and serialized 3 users: 4 queries, one for the users and one per user. With 500 users that is 501 queries. The alternative is to let the database count in the same query:

User::withCount('blogPosts')->get()->toArray();
// 1 query, and the output has a blog_posts_count field
Enter fullscreen mode Exit fullscreen mode

Keep appended accessors cheap and based on data the model already has.

Control date formats

By default, dates are serialized in ISO 8601 form. My created_at came out as 2026-01-01T10:00:00.000000Z. To change it for a single column, give the cast a format:

protected function casts(): array
{
    return [
        'birthday'  => 'date:Y-m-d',
        'joined_at' => 'datetime:Y-m-d H:00',
    ];
}
Enter fullscreen mode Exit fullscreen mode

To change every date on the model, override serializeDate():

use DateTimeInterface;

protected function serializeDate(DateTimeInterface $date): string
{
    return $date->format('Y-m-d');
}
Enter fullscreen mode Exit fullscreen mode

With that override, created_at became 2026-01-01. The stored database value of birthday was unchanged by the cast format: formatting affects only array and JSON output, as the docs say.

When to move to API resources

The documentation itself points to Eloquent API resources for more robust JSON output. Serialization is great for internal tools and simple endpoints. Once your API response needs a stable shape that differs from your table structure, versioning, or conditional fields, a resource class is clearer than a pile of makeHidden() calls.

Quick checklist

  • Passwords, tokens and internal flags: Hidden, or better, an allow list with Visible.
  • Remember that hidden does not stop direct access like $user->password.
  • makeVisible() and makeHidden() change the object, so do not reuse it.
  • Appended accessors run per row: avoid queries inside them.
  • Date format: per-column in casts(), or everywhere with serializeDate().

References


Originally published on DEV Talk.

Top comments (0)