laravel: powerful json with jsonSerialize()
Tags: php, laravel
Published: 2022-05-03 19:46:20

laravel does a pretty good job of transparently translating models to json so long as you keep things simple. however, that ease of use breaks down pretty quickly once you decide to make meaninful edits to your json representations.  if you've ever found yourself fighting with `hidden` vs. `visible`, or fiddling with `casts` or accessors, you probably understand.

fortunately, laravel provides an easy way to define a model's json serialization in one method: `jsonSerialize()`.

## why use jsonSerialize?
although not the 'canonical' way to do serialization, `jsonSerialize()` provides both power and improved readability.

by using `jsonSerialize()` we can add, delete or modify any field in our model without having to resort to mixing various configuration values. and because our json definition is all in one method, our code is a bit more readable.

## the flyover
we can do a lot with `jsonSerialize`, but this article is only going to cover the basics, namely:

* a simple implementation to modify json structure and returned data
* including a relationship in our json
* subclassing models to create new serialization schemas


## the data structure
since we're working with models here, we're going to do a fast explanation of the project and data structure for the examples that follow. 

our project is a simple api for viewing a vinyl record collection. the data structure consists of `records`, with each record having one more more `songs`.

the api has three endpoints:

* `GET http://api.bintracker.test/api/songs` to return all the songs in the database
* `GET http://api.bintracker.test/api/records` to return a digest of all the records
* `GET http://api.bintracker.test/api/records/{id}` to return details on one record, including all of that record's songs.

the migrations for our tables are shown at the bottom of the article.

we will be going over the models and the controllers in the rest of the article.

## a simple json format with jsonSerialize
the first model we're going to look at is `Song.php`. 

before adding `jsonSerialize`, the model looks like this:

**app/Models/Song.php**
```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Song extends Model
{
    use HasFactory;

    protected $table = 'songs';
}
```

when we call our `GET /api/songs` endpoint to return all songs in our db, the result looks like:

```json
[
  {
    "id": 1,
    "record_id": 1,
    "title": "Bait and Switch",
    "duration_seconds": 148,
    "created_at": "2022-05-02T22:32:01.000000Z",
    "updated_at": "2022-05-02T22:32:01.000000Z"
  },
  {
    "id": 2,
    "record_id": 1,
    "title": "Sneaking into yer House",
    "duration_seconds": 80,
    "created_at": "2022-05-02T22:32:14.000000Z",
    "updated_at": "2022-05-02T22:32:14.000000Z"
  },
  {
    "id": 3,
    "record_id": 1,
    "title": "Dippity Do-Nut",
    "duration_seconds": 112,
    "created_at": "2022-05-03T11:16:25.000000Z",
    "updated_at": "2022-05-03T11:16:25.000000Z"
  }
]
```

that's not great output. let's fix it with `jsonSerialize()`!

at the bottom of our `Song` model, we'll add this method:

```php
    /**
     * Define the json format of the model
     */
    public function jsonSerialize():Array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            // cast seconds to minutes and seconds
            'duration' => gmdate("i:s", $this->duration_seconds),
            // a new value
            'long_enough_for_radio_play' => $this->duration_seconds > 120 ? true: false,
        ];
    } // jsonSerialize
```

let's take a look at what's going on here.

first we declare the public method `jsonSerialize()`. this method is called every time this model is serialized to json for both single objects and collections.

we notice that it returns an associative array. this array is what defines our json. in the example above, we have explicitly included `id` and `title` but have left out both the record timestamps. the result is that `id` and `title` show up in our json but, for instance, `created_at` does not.

we can also see that we have applied a transformation to the `duration_seconds` column to convert seconds to minutes and seconds, assigning it to the key `duration`, and have added a new key/value of `long_enough_for_radio_play` to indicate if the song is more than two minutes long.

this is one of the more powerful aspects of `jsonSerialize()`: we can apply casts and transformations to any column we want, rename keys, and even inject new key/value pairs.

when we call `GET /api/songs` again, our json output now looks like:

```json
[
  {
    "id": 1,
    "title": "Bait and Switch",
    "duration": "02:28",
    "long_enough_for_radio_play": true
  },
...
]
```

our new keys are there and the timestamps are gone!


## composite objects with jsonSerialize
so far we've looked at serializing simple objects. now, let's move on to composite objects: objects that contain other objects.

let's look at the model for `Record`:

**app/Models/Record.php**
```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Record extends Model
{
    use HasFactory;

    protected $table = 'records';

    /**
     * Returns all songs for record
     *
     * @return HasMany
     */
    protected function songs():HasMany
    {
        return $this->hasMany('App\Models\Song', 'record_id', 'id')->orderBy('id', 'asc');
    }

    /**
     * Define the json format of the model
     */
    public function jsonSerialize():Array
    {
        return [
            'id' => $this->id,
            'artist' => $this->artist,
            'title' => $this->title,
            'songs' => $this->songs, // array of songs
        ];
    } // jsonSerialize
}
```

since records contain songs, we want our `Record` model to include an array of that record's `Song` models. doing this requires two things.

first, we write the `songs()` function. this function creates a `hasMany()` relationship to `songs` and returns it. if you've done a lot of eloquent before, this should be familiar.

then, in our `jsonSerialize()` method, we include those songs by calling the `songs()` method. the result is an array of songs keyed as `songs`.

we should note that the json format of all those songs is defined by the `Song` model's own `jsonSerialize()` method. very handy!

once we have this written, our endpoint `GET /api/records/{id}`, which returns `Record::all()`, will give us output that looks like this:

```json
{
  "id": 1,
  "artist": "Emily's Sassy Lime",
  "title": "Dippity Do-Nut",
  "songs": [
    {
      "id": 1,
      "title": "Bait and Switch",
      "duration": "02:28",
      "long_enough_for_radio_play": true
    },
    {
      "id": 2,
      "title": "Sneaking into yer House",
      "duration": "01:20",
      "long_enough_for_radio_play": false
    },
    {
      "id": 3,
      "title": "Dippity Do-Nut",
      "duration": "01:52",
      "long_enough_for_radio_play": false
    }
  ]
}
```

## subclassing for different formats
sometimes, we want two different json schemas for the same model. for instance, if we wanted to write an endpoint that returned a list of *all* our records, we'd probably want a array of smaller objects; a 'digest'. maybe we just want the `artist`, `title` and `id` fields.

we can do this by subclassing our `Record` model and overloading `jsonSerialize`. by making our new model, let's call it `RecordDigest`, a subclass of `Record`, we can access all of the methods and data in the superclass if we want, but our default json serialization will be our new one. let's look at the `RecordDigest` model.

**app/Models/RecordDigest.php**
```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class RecordDigest extends Record
{

    /**
     * Define the json format of the model
     */
    public function jsonSerialize():Array
    {
        return [
            'id' => $this->id,
            'artist' => $this->artist,
            'title' => $this->title,
        ];
    } // jsonSerialize
}
```

this is a whole new model, but because it inherits from `Record`, it still accesses the `records` table and we still have access to the `songs()` method if we wish to use it.

if we write an endpoint `GET /api/records` that retuns `RecordDigest::all()`, we will see our json output looks like:

```json
[
  {
    "id": 1,
    "artist": "Emily's Sassy Lime",
    "title": "Dippity Do-Nut"
  },
  {
    "id": 2,
    "artist": "Bratmobile",
    "title": "Pottymouth"
  }
]
```

exactly what we wanted.

## conclusion
there's a lot we can do with `jsonSerialize()`. i often use it as a way to inject [hateoas](https://restfulapi.net/hateoas/) data into my returns, or to tailor json data or structure depending on, say, the access role of the user. by creating these formalized ways to define our json we get not just power, but also consistency and improved readability.


## supplemental code
if you want to inspect this example more, the source is listed here:

### models

**Song.php**
```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Song extends Model
{
    use HasFactory;

    protected $table = 'songs';

    /**
     * Define the json format of the model
     */
    public function jsonSerialize():Array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            // cast seconds to minutes and seconds
            'duration' => gmdate("i:s", $this->duration_seconds),
        ];
    } // jsonSerialize
}
```

**Record.php**
```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Record extends Model
{
    use HasFactory;

    protected $table = 'records';

    /**
     * Returns all songs for record
     *
     * @return HasMany
     */
    protected function songs():HasMany
    {
        return $this->hasMany('App\Models\Song', 'record_id', 'id')->orderBy('id', 'desc');
    }

    /**
     * Define the json format of the model
     */
    public function jsonSerialize():Array
    {
        return [
            'id' => $this->id,
            'artist' => $this->artist,
            'title' => $this->title,
            'songs' => $this->songs, // array of songs
        ];
    } // jsonSerialize
}
```

**RecordDigest.php**
```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class RecordDigest extends Record
{

    /**
     * Define the json format of the model
     */
    public function jsonSerialize():Array
    {
        return [
            'id' => $this->id,
            'artist' => $this->artist,
            'title' => $this->title,
        ];
    } // jsonSerialize
}
```


### controller

**RecordController.php**
```php
<?php

namespace App\Http\Controllers\api;

use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;

use App\Models\Song;
use App\Models\Record;
use App\Models\RecordDigest;

class RecordController extends Controller
{
    /**
     * Get all songs
     *
     * @return JsonResponse
     */
    public function getAllSongs(Request $request):JsonResponse
    {
        $songs = Song::all();

        return response()->json($songs, 200);
    }

    /**
     * Get one record
     *
     * @return JsonResponse
     */
    public function getRecord(Int $id, Request $request):JsonResponse
    {
        $records = Record::find($id);

        return response()->json($records, 200);
    }

    /**
     * Get all records as digests
     *
     * @return JsonResponse
     */
    public function getAllRecords(Request $request):JsonResponse
    {
        $records = RecordDigest::all();

        return response()->json($records, 200);
    }
```

### routes

**routes/api.php**
```php
Route::get('songs', '\App\Http\Controllers\api\RecordController@getAllSongs');
Route::get('records', '\App\Http\Controllers\api\RecordController@getAllRecords');
Route::get('records/{id}', '\App\Http\Controllers\api\RecordController@getRecord');
```

### migrations

**records migration**

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateRecordsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('records', function (Blueprint $table) {
            $table->id();
            $table->string('artist');
            $table->string('title');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('records');
    }
}
```

**songs migration**

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateSongsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('songs', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('record_id');
            $table->foreign('record_id')->references('id')->on('records');
            $table->string("title");
            $table->integer("duration_seconds");
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('songs');
    }
}
```