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
- Copy one representative record from the API response. If it is minified, tidy it in the JSON formatter so you can read every field.
- 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. - Save the model as
app/Models/Order.php. For the migration, runphp artisan make:migration create_orders_tableand replace the body of the generated file, so the timestamp in the file name is correct. - 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 value | Migration column | Model cast |
|---|---|---|
integer ending in _id | foreignId()->constrained() | none, plus belongsTo |
| other integer | integer (or bigInteger for very large values) | integer |
| number with decimals | decimal(10, 2) | decimal:2 |
true / false | boolean()->default(false) | boolean |
| ISO date or date-time string | date / timestamp, nullable | date / datetime |
| object or array | json()->nullable() | array |
| other string | string, or text above 255 characters | none |
null | string()->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
customersmigration needs an earlier timestamp thanorders, ormigratefails when it adds the constraint. - The guessed table may be wrong. An
author_idthat points to users needsconstrained('users'), and the relation needsbelongsTo(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()ornullOnDelete()(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, notinteger. - Optional fields are
nullable(), and long text usestext. - Foreign keys point to the right tables, with a delete rule you chose deliberately.
- Unique indexes exist only where values really are unique.
$fillablecontains 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.