Skip to main content

Modeling the subject and seeding data — tables, models, and seeders

This chapter prepares the tables and models used across the next 19 chapters.

The up-front investment here pays off in every later chapter. Splitting stock into its own table makes row locking straightforward to write in chapter 14. Keeping the unit price on the order line means past orders do not break when you change a product's price. Cut corners here and you will be back at the table definitions in every later chapter.

The tables for the order system​

We create four tables.

Here are three reasons for this shape. They are not general table design theory but reasons this structure is needed in later chapters.

Stock does not live on products​

Adding a stock_quantity column to products would leave you with three tables. We split it anyway because product information and stock quantity differ in how they are updated.

Product names and descriptions rarely change. Stock quantity changes with every order. In chapter 14, when we decrement stock, we lock the relevant row so other requests wait. If that lock were on products, it would catch processing that rewrites the product's information as well. While orders are pouring in, a price revision or a description fix would stall. Put stock in its own table and the lock stays confined to the stock row.

Order lines carry the unit price​

order_items has a product_id, so it looks as though the price could be pulled from products. Do that, though, and the moment you change a product's price, the amounts of past orders change too.

An order records what something cost at the time. So we copy the unit price at order time onto order_items. For the same reason, orders carries a total_amount.

Amounts are held as integers​

price, unit_price, and total_amount are all integer, in yen. We do not use float.

Floating point cannot represent decimal fractions exactly. For the same reason 0.1 + 0.2 is not 0.3, an amount calculation ends up off by a yen. For a currency without a subunit in play, such as the Japanese yen, hold integer yen; for a currency with cents, such as the dollar, hold integer cents.

If you need to be stricter

In a system doing real accounting, using a decimal type on the database side is an option. Table design as a subject is covered by the Database Design Guide, so see that as well. This series keeps things simple and sticks with integer yen.

Writing the migrations​

Laravel manages table definitions as PHP code. That is a migration.

php artisan make:migration create_products_table

Write the definition into the generated file.

database/migrations/xxxx_create_products_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('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->integer('price');
$table->timestamps();
});
}

public function down(): void
{
Schema::drop('products');
}
};

The return new class extends Migration at the top is PHP's anonymous class syntax, defining a class on the spot without naming it. Migration files pile up by the dozen in the same directory, so this form exists to avoid class name collisions.

$table->id() makes the primary key and $table->timestamps() makes created_at and updated_at. up() applies the change and down() rolls it back.

Adding foreign key constraints​

stocks references products.

database/migrations/xxxx_create_stocks_table.php
public function up(): void
{
Schema::create('stocks', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->unique()->constrained()->cascadeOnDelete();
$table->integer('quantity');
$table->timestamps();
});
}

foreignId('product_id') creates an UNSIGNED BIGINT column. The constrained() that follows adds a foreign key constraint, inferring the referenced table and column from the column name. product_id means the id of the products table.

unique() is there because the design gives each product exactly one stock row. cascadeOnDelete() says the stock row goes when the product does.

The point of a foreign key constraint is that the database refuses inconsistency even when the application has a bug. A stock row with a nonexistent product_id fails at INSERT time when the constraint exists. Without it, the row goes in quietly and turns up later as "a stock row whose product cannot be found."

When you depart from the naming convention, you can name the reference explicitly.

$table->foreignId('created_by')->constrained(table: 'users');

orders and order_items are built the same way.

database/migrations/xxxx_create_orders_table.php
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('customer_email');
$table->string('status')->default('pending');
$table->integer('total_amount');
$table->timestamps();
});
database/migrations/xxxx_create_order_items_table.php
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_id')->constrained();
$table->integer('quantity');
$table->integer('unit_price');
$table->timestamps();
});

order_id gets cascadeOnDelete() so that lines go when the order does. product_id does not, because being able to delete an ordered product would delete past order lines. With the constraint in place, deleting the product fails. That is the correct behavior.

Apply them.

php artisan migrate

Along with the four you just wrote, this command also applies the migrations Laravel ships with: tables such as users (registered users), cache, and jobs (the queue). We use them in chapters 11, 18, and 16 respectively, so for now it is enough to know they exist.

Creating the Eloquent models​

Create the classes that correspond to the tables.

php artisan make:model Product
php artisan make:model Stock
php artisan make:model Order
php artisan make:model OrderItem

Laravel treats the plural of the class name as the table name. Product maps to products, OrderItem to order_items. Follow the naming convention and you never have to specify the table name.

Representing order state with an enum​

orders.status is a string column, but the values that go in it are fixed. We express them with a PHP backed enum.

app/Enums/OrderStatus.php
<?php

namespace App\Enums;

enum OrderStatus: string
{
case Pending = 'pending';
case Confirmed = 'confirmed';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
}

On the model side, declare that this column is handled as the enum.

The #[Fillable([...])] in the code below is PHP 8 attribute syntax. It is a marker placed on a class or method that the framework reads to change behavior. It starts with #, but it is not a line comment. Everything from #[ to the matching ] is one piece of syntax and is never ignored as a comment.

app/Models/Order.php
<?php

namespace App\Models;

use App\Enums\OrderStatus;
use Database\Factories\OrderFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

#[Fillable(['customer_email', 'status', 'total_amount'])]
class Order extends Model
{
/** @use HasFactory<OrderFactory> */
use HasFactory;

/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'status' => OrderStatus::class,
];
}
}

Write it in the casts() method and the string 'pending' read from the database comes out as OrderStatus::Pending1. You can pass the enum straight in when saving, too.

$order = Order::find(1);

$order->status; // OrderStatus::Pending (an enum)
$order->status === OrderStatus::Pending; // true

$order->status = OrderStatus::Confirmed; // assignable as the enum
$order->save();

With an enum, a typo becomes a runtime error. Mix up 'confirm' and 'confirmed' as plain strings and nothing tells you.

Closing off mass assignment​

Passing an array wholesale into a model, as in Order::create($request->all()), is called mass assignment. It is convenient, and dangerous as it stands. Slip "is_admin": true into the request JSON and that column gets written too.

Laravel prevents this by default. Unless you declare which attributes mass assignment accepts, the create method cannot be used at all2.

The declaration goes in a #[Fillable] attribute.

✅ Good: enumerate the attributes you accept
#[Fillable(['customer_email', 'status', 'total_amount'])]
class Order extends Model
{
// ...
}
❌ Bad: allow everything
#[Unguarded]
class Order extends Model
{
// ...
}

#[Unguarded] lets every attribute through mass assignment. It is a declaration that removes the defense Laravel gives you by default, so this series does not use it.

Older articles use the $fillable property

The Laravel 13 documentation and the standard User model use the #[Fillable([...])] attribute form. The protected $fillable = [...] you see in slightly older articles is the property form; only the place you write it differs, and the purpose is the same. Likewise, $hidden corresponds to #[Hidden([...])] and $guarded = [] to #[Unguarded].

The remaining three models take the same form. Any model that uses mass assignment needs #[Fillable]. Without the declaration, calling create() throws.

app/Models/OrderItem.php
#[Fillable(['product_id', 'quantity', 'unit_price'])]
class OrderItem extends Model
{
/** @use HasFactory<OrderItemFactory> */
use HasFactory;
}
app/Models/Stock.php
#[Fillable(['product_id', 'quantity'])]
class Stock extends Model
{
/** @use HasFactory<StockFactory> */
use HasFactory;
}

The absence of order_id from OrderItem's #[Fillable] is deliberate. When we create lines with $order->items()->create([...]) in chapter 6, the relationship supplies order_id. Listing it here would leave room for an array coming from a request to hang lines off a different order.

Defining relationships​

Write the connections between models.

Added to app/Models/Order.php
use Illuminate\Database\Eloquent\Relations\HasMany;

public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
Added to app/Models/Product.php
use Illuminate\Database\Eloquent\Relations\HasOne;

public function stock(): HasOne
{
return $this->hasOne(Stock::class);
}

Now you can follow $order->items and $product->stock. Fetching through relationships is covered properly in chapters 7 and 8, where we also get to the N+1 problem.

Preparing development data with factories and seeders​

Tables and models are no use for running an API while they are empty. A factory lets you create plausible data in any quantity.

php artisan make:factory ProductFactory
database/factories/ProductFactory.php
<?php

namespace Database\Factories;

use Illuminate\Database\Eloquent\Factories\Factory;

/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Product>
*/
class ProductFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->words(3, true),
'description' => fake()->sentence(),
'price' => fake()->numberBetween(500, 50000),
];
}
}

definition() returns the default state. fake() is a helper that generates dummy data.

Add the HasFactory trait to the model and you can call Product::factory(). The standard User model has it too.

app/Models/Product.php
use Database\Factories\ProductFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;

#[Fillable(['name', 'description', 'price'])]
class Product extends Model
{
/** @use HasFactory<ProductFactory> */
use HasFactory;
}

The /** @use HasFactory<ProductFactory> */ docblock conveys the type to static analysis tools. The standard User model is written the same way.

Create factories for Order, OrderItem, and Stock in the same fashion, and add HasFactory to each. That is why the Order model example in this chapter imports OrderFactory.

OrderFactory is used by the tests from chapter 10 onward, so we make it here.

database/factories/OrderFactory.php
use App\Enums\OrderStatus;

/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'customer_email' => fake()->unique()->safeEmail(),
'status' => OrderStatus::Pending,
'total_amount' => fake()->numberBetween(1000, 100000),
];
}

All three columns on orders are NOT NULL, so every one has to be filled in. Miss one and the test errors the moment it calls create().

Creating a specific state​

Sometimes you want a state other than the default, such as "a product that is out of stock." Define a state method.

database/factories/StockFactory.php
public function definition(): array
{
return [
'product_id' => Product::factory(),
'quantity' => fake()->numberBetween(1, 100),
];
}

/**
* The out-of-stock state
*/
public function soldOut(): static
{
return $this->state(fn (array $attributes) => [
'quantity' => 0,
]);
}

Writing 'product_id' => Product::factory() creates the associated product along with the stock row.

You use it like this.

Stock::factory()->create(); // in stock
Stock::factory()->soldOut()->create(); // zero stock

This soldOut() is used in chapter 14 when we write the out-of-stock test. Instead of overwriting quantity with 0 in every test, it puts a name to the intent.

Loading data in bulk with a seeder​

A seeder is the mechanism that calls factories to create development data all at once.

database/seeders/DatabaseSeeder.php
<?php

namespace Database\Seeders;

use App\Models\Product;
use App\Models\Stock;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
public function run(): void
{
Product::factory()
->count(20)
->has(Stock::factory())
->create();
}
}

has(Stock::factory()) creates a stock row for each product.

php artisan db:seed

When you want to rebuild the tables and then run the seeder, one command does it.

php artisan migrate:fresh --seed

What matters in production​

Never run migrate:fresh in production​

migrate:fresh drops every table and rebuilds them. Handy locally; run it in production and the data is gone. Because the name starts with migrate, it is entirely possible to accept a tab completion while meaning to type php artisan migrate.

The only thing you run in production is php artisan migrate --force. --force skips the "are you sure, this is production" confirmation, which is what you need when calling it from a deployment script. Chapter 21's deployment procedure returns to this.

Do not run seeders in production​

What a seeder creates is dummy development data. You would end up with 20 fake()-generated products sitting in the production database.

The remedy is either to check the environment inside the seeder, or simply not to put db:seed in the deployment procedure at all. If you need to load master data, keep it in a separate class from the dummy data seeder.

Design migrations to move forward only​

The down() method makes rollback look possible, but it is safer not to count on it in production. Roll back a migration that dropped a column and the data that was in it does not come back.

The way to handle a production problem is not to roll back but to add a new migration that fixes it. Think of down() as something for trial and error locally, and you will not misdesign.

That stance also affects the procedure for removing a column. Go in stages: add the new column, write to both, switch reads to the new one, then drop the old one. Drop it in a single deployment and, for a moment during the deployment, you get the combination "new code plus old schema" or "old code plus new schema," and that is where the errors come from.

Design deletion cascades deliberately​

cascadeOnDelete() is convenient, but check what it takes with it before adding it. Had we put it on order_items.product_id, deleting one product would delete every past order line containing that product. Sales totals would stop adding up, and tracking down the cause would take time.

This series adds cascadeOnDelete() in two places: stocks.product_id (the stock row goes when the product does) and order_items.order_id (the lines go when the order does). In both cases the data is meaningless without its parent. order_items.product_id, by contrast, does not get it.

As a result, a product that has ever been ordered can no longer be deleted. The foreign key constraint on order_items refuses the delete first, so it never reaches the cascade on stocks. Failing to delete is the correct behavior. If you want to stop handling a product while keeping past orders, express it with a discontinued flag or a soft delete rather than a delete.

Summary​

  • Stock lives in its own table so that chapter 14 can lock only the stock row
  • The unit price is copied onto the order line so that changing a product's price does not change past orders
  • Amounts are held as integers. float cannot represent decimal fractions exactly
  • In Laravel 13, mass assignment permission is declared with the #[Fillable] attribute. Do not open everything up with #[Unguarded]
  • Specifying a backed enum in the casts() method lets you handle state as a type instead of a string
  • A factory's state method is a way to put a name to the situation you want in a test

The next chapter, Routing design and your first endpoint, finally builds the first endpoint. We run php artisan install:api to get routes/api.php, and define routes in bulk with apiResource(). What to expose in the URL, and how {product} becomes a model, are settled there too.

Exercises​

What problems come from a design that omits total_amount on the orders table and computes a SUM over order_items each time?

Sample answer

The calculation itself works correctly. The problems come from two directions.

Performance: an API returning a list of orders would compute the line total for each of 100 orders. It is the same structure as the N+1 problem covered in chapter 8, and it slows down in proportion to the count.

Correctness: this is the more fundamental one. A total contains elements a plain sum of the lines cannot express: shipping fees, coupon discounts, rounding adjustments from a campaign. When you add those later, a SUM-based design has nowhere to put them.

Holding total_amount looks redundant, but it is natural once you see it as recording the fact of what this order cost. Whether it matches the sum of the lines is for the business rules of that moment to decide.

The following model has a problem. Point it out
use Illuminate\Database\Eloquent\Attributes\Unguarded;

#[Unguarded]
class User extends Authenticatable
{
protected function casts(): array
{
return [
'password' => 'hashed',
];
}
}

Sample answer

#[Unguarded] lets every attribute through mass assignment.

If the users table has a column expressing privileges, such as is_admin or role, then sending the following JSON to the sign-up API is enough to become an administrator.

{
"name": "Test User",
"email": "test@example.com",
"password": "password",
"is_admin": true
}

If the registration code is written as User::create($request->validated()) and the validation rules do not include is_admin, it is prevented. But that leaves you in a state where the defense depends entirely on how the validation rules are written. The moment one rule is forgotten, a hole opens.

Declare #[Fillable(['name', 'email', 'password'])] and everything else is discarded no matter what is mixed into the request. Laravel forbids mass assignment by default precisely so that this defense is not removed.

Note that silently discarding values not in this attribute has a downside of its own: it is hard to notice. If you want an exception thrown during development, you can call Model::preventSilentlyDiscardingAttributes() in the boot method of AppServiceProvider3.


Footnotes​

  1. Source: Eloquent: Mutators & Casting (Laravel documentation 13.x). On casting an attribute to a PHP enum with the casts method. ↩

  2. Source: Eloquent: Getting Started (Laravel documentation 13.x). On needing a Fillable or Guarded attribute declaration before using create, and on Eloquent protecting against mass assignment vulnerabilities by default. ↩

  3. Source: same as above. On attributes not included in Fillable being silently discarded by default, and on making them throw with the preventSilentlyDiscardingAttributes method. ↩