FormatterHQ

PHP & Laravel

Laravel Model and Migration from JSON: A Practical Guide

Turn a sample API payload into an Eloquent model and migration, then review the column types, casts, foreign keys and nullable fields a single record cannot reveal.

FormatterHQ Team Published Updated 9 min read

When you start storing data from a third-party API, the first chore is writing a migration and an Eloquent model that match the payload. This guide walks through building a Laravel model from JSON: we take a real API record, generate the migration and model with our JSON to Laravel model generator, and then go through every decision you should review by hand, from column types and casts to foreign keys, $fillable and soft deletes.

The examples target Laravel 11 and later, which use anonymous migration classes and the casts() method on models.

Generate a Laravel model from JSON in four steps

  1. Copy one representative record from the API response. If it is minified, tidy it in the JSON formatter so you can read every field.
  2. Paste it into the JSON to Laravel model and migration tool and set the model name, for example Order. Leave the table blank to use the plural (orders) or type a custom one.
  3. Save the model as app/Models/Order.php. For the migration, run php artisan make:migration create_orders_table and replace the body of the generated file, so the timestamp in the file name is correct.
  4. Review the output (see the checklist below), then run php artisan migrate.

If the tool reports a parse error, the JSON itself is broken; our guide on how to debug invalid JSON covers the usual causes, and the JSON validator pinpoints the line.

Example: an order record from an API

Here is a typical e-commerce payload with camelCase keys, a nested address, an array of tags, dates and nulls:

{
  "id": 1024,
  "customerId": 58,
  "orderNumber": "ORD-2024-1024",
  "email": "ann@example.com",
  "status": "paid",
  "total": 149.9,
  "itemCount": 3,
  "isGift": false,
  "notes": null,
  "shippingAddress": { "city": "Leeds", "postcode": "LS1 4AP" },
  "tags": ["priority", "wholesale"],
  "paidAt": "2024-06-01T10:15:00Z",
  "deliveryDate": "2024-06-05",
  "createdAt": "2024-06-01T10:00:00Z",
  "updatedAt": "2024-06-01T10:15:00Z",
  "deletedAt": null
}

With the model name set to Order, the generator produces this model:

// app/Models/Order.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;

class Order extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'customer_id',
        'order_number',
        'email',
        'status',
        'total',
        'item_count',
        'is_gift',
        'notes',
        'shipping_address',
        'tags',
        'paid_at',
        'delivery_date',
    ];

    protected function casts(): array
    {
        return [
            'total' => 'decimal:2',
            'item_count' => 'integer',
            'is_gift' => 'boolean',
            'shipping_address' => 'array',
            'tags' => 'array',
            'paid_at' => 'datetime',
            'delivery_date' => 'date',
        ];
    }

    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }
}

and this migration:

// database/migrations/xxxx_xx_xx_000000_create_orders_table.php
<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->foreignId('customer_id')->constrained()->cascadeOnDelete();
            $table->string('order_number');
            $table->string('email')->unique();
            $table->string('status');
            $table->decimal('total', 10, 2);
            $table->integer('item_count');
            $table->boolean('is_gift')->default(false);
            $table->string('notes')->nullable();
            $table->json('shipping_address')->nullable();
            $table->json('tags')->nullable();
            $table->timestamp('paid_at')->nullable();
            $table->date('delivery_date')->nullable();
            $table->timestamps();
            $table->softDeletes();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('orders');
    }
};

Several things were inferred automatically. camelCase keys became snake_case columns, id was skipped because $table->id() already creates the primary key, createdAt and updatedAt collapsed into $table->timestamps(), and the presence of deletedAt added softDeletes() plus the SoftDeletes trait. The integer customerId was recognised as a foreign key and got a customer() relation.

How JSON values map to column types

JSON has only a handful of types, so the generator infers database columns from the value and the key name:

JSON valueMigration columnModel cast
integer ending in _idforeignId()->constrained()none, plus belongsTo
other integerinteger (or bigInteger for very large values)integer
number with decimalsdecimal(10, 2)decimal:2
true / falseboolean()->default(false)boolean
ISO date or date-time stringdate / timestamp, nullabledate / datetime
object or arrayjson()->nullable()array
other stringstring, or text above 255 charactersnone
nullstring()->nullable()none

The weak spot is that a single value can mislead. The generator reads the JSON text, so a number written with a decimal point such as 150.00 becomes a decimal column, but a price that the API prints as a bare 20 in your sample becomes an integer column. From {"total": 150.00, "price": 20, "externalId": "a1b2"} the generator writes:

        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->decimal('total', 10, 2);
            $table->integer('price');
            $table->string('external_id');
        });

Always pick a sample where money fields are written with their decimals, or change such columns to decimal by hand. Likewise, a null value only tells the generator that the field can be empty, not what it holds, so notes became a nullable string when text may be a better fit.

Casts, $fillable and $guarded

The casts() method converts raw database values into useful PHP types every time you read an attribute. boolean turns 0/1 into false/true, datetime returns a Carbon instance, and array encodes the JSON column on save and decodes it on read. Note that the decimal:2 cast returns a string such as "149.90", which avoids floating-point rounding for money. The Eloquent casting documentation lists further options, such as casting status to a backed PHP enum with 'status' => OrderStatus::class.

The generator lists every column in $fillable, which allows mass assignment through create() and update(). That is convenient for an importer, but review it before any user input reaches the model. Columns that should never be set from a request, like a total calculated on the server, belong outside $fillable. The alternative is protected $guarded = [];, which allows everything and puts the whole responsibility on validated input; keeping an explicit $fillable list is the safer default. A field named password is handled specially: it gets the hashed cast and is added to $hidden.

Foreign keys and relationships

$table->foreignId('customer_id')->constrained() creates an unsigned big integer column and a foreign key to the id column of a table guessed from the column name, here customers. Check three things:

  • The parent table must exist first. The customers migration needs an earlier timestamp than orders, or migrate fails when it adds the constraint.
  • The guessed table may be wrong. An author_id that points to users needs constrained('users'), and the relation needs belongsTo(User::class, 'author_id').
  • Cascading deletes are rarely right for business records. The generator emits cascadeOnDelete(), which deletes a customer's orders along with the customer. For orders, restrictOnDelete() or nullOnDelete() (with a nullable column) is usually safer.

Also think about the API's own id. The generator drops it because your table has its own primary key, but you still need it to match records on the next sync. Store it in a separate unique column such as external_id.

Timestamps, soft deletes and nullable columns

$table->timestamps() adds nullable created_at and updated_at columns that Eloquent fills automatically. When your sample has no timestamp fields, the model gets public $timestamps = false; so Eloquent does not try to write columns that do not exist. softDeletes() adds a nullable deleted_at; with the trait, delete() sets that column instead of removing the row, and normal queries skip trashed rows.

Nullability is the setting a single sample reveals least. From one record, only null values, dates and JSON columns are nullable in the output; every other column is required. Compare several API responses and add ->nullable() to any field that is sometimes missing, otherwise inserts will fail with a constraint error. Pasting an array of records into the tool does part of this for you: it merges their keys, marks a field that is null or missing in any record as nullable, and when two records disagree on a type the wider one is used (decimal over integer, string over number), whatever the order of the records.

Reviewing, running and filling the table

After reviewing, the order migration from our example might look like this. We added the external ID, tightened string lengths, added indexes for columns we filter on, removed the unique index on email (the generator adds one to a field named email, but one customer places many orders) and switched to restrictOnDelete():

Schema::create('orders', function (Blueprint $table) {
    $table->id();
    $table->unsignedBigInteger('external_id')->unique();
    $table->foreignId('customer_id')->constrained()->restrictOnDelete();
    $table->string('order_number', 32)->unique();
    $table->string('email');
    $table->string('status', 20)->index();
    $table->decimal('total', 10, 2);
    $table->unsignedInteger('item_count');
    $table->boolean('is_gift')->default(false);
    $table->text('notes')->nullable();
    $table->json('shipping_address')->nullable();
    $table->json('tags')->nullable();
    $table->timestamp('paid_at')->nullable();
    $table->date('delivery_date')->nullable();
    $table->timestamps();
    $table->softDeletes();
});

Then run the migration, and roll it back if you need to change it again:

php artisan migrate
php artisan migrate:rollback

The Laravel migrations documentation lists every column type and modifier. If you are starting from an existing CREATE TABLE statement instead of JSON, use the SQL to Laravel migration converter.

Saving API data into the new model

With the table in place, an importer only needs to convert the keys to snake_case and keep the fillable fields. This sketch uses the Laravel HTTP client; our guide to converting curl commands to Guzzle explains how to translate the request from an API's curl example.

use App\Models\Order;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

$payload = Http::withToken(config('services.shop.token'))
    ->get('https://api.example.com/orders/1024')
    ->throw()
    ->json();

$attributes = collect($payload)
    ->mapWithKeys(fn ($value, $key) => [Str::snake($key) => $value])
    ->all();

Order::updateOrCreate(
    ['external_id' => $attributes['id']],
    Arr::only($attributes, (new Order)->getFillable()),
);

Add 'external_id' to the model's $fillable list as well, otherwise Eloquent silently drops it when creating the row. updateOrCreate() then matches on external_id, so running the import twice updates the row instead of duplicating it. The array casts encode the address and tags, and the datetime cast parses the ISO timestamps. Nested objects that deserve their own table, such as line items, need a second model generated the same way with an order_id key.

Final checklist

  • Decimal fields are decimal, not integer.
  • Optional fields are nullable(), and long text uses text.
  • Foreign keys point to the right tables, with a delete rule you chose deliberately.
  • Unique indexes exist only where values really are unique.
  • $fillable contains only fields you are happy to mass-assign.
  • The API's own ID is stored for future syncs.

Generating a Laravel model from JSON removes the typing, but the schema decisions stay with you. Start with the JSON to Laravel model generator, apply the checklist, and tidy the final files in the PHP formatter before committing.

  • Laravel
  • Eloquent
  • Migrations
  • JSON
  • PHP

Frequently asked questions

Does the generated code work on Laravel 10?
Mostly. The anonymous migration class also works on Laravel 9 and 10, but the casts() method was introduced in Laravel 11. On older versions, move the returned array into a protected $casts property.
Why is my price column an integer?
The generator picks decimal only when the sample writes the number with a decimal point, such as 150.00 or 149.9; a price written as a bare 150 is read as an integer. Use a sample that shows the cents, or change the column to decimal('price', 10, 2) and the cast to decimal:2 by hand.
Can I generate models for nested JSON objects?
Nested objects and arrays become a single json column with an array cast. If the nested data needs its own table, paste the nested object separately with its own model name and add an order_id-style key to link it back.
Should I use $fillable or $guarded?
Both control mass assignment. An explicit $fillable list is safer because new columns are protected until you allow them, while $guarded = [] allows every attribute and relies entirely on validated input.

Tools used in this guide