Building an order system in Laravel: a step-by-step guide
Most production e-commerce platforms live or die by how well they handle orders. We've all seen the aftermath of a system that doesn't validate stock before committing a purchase, or fails to notify customers when something goes wrong. Building an order system in Laravel: a step-by-step guide takes you through the real patterns we use in production—from models and relationships through to async event handling and comprehensive tests.
Why order systems matter
An order is rarely a single record. It's a coordination point: inventory allocation, payment processing, customer communication, and fulfillment all converge at that moment a user clicks "buy now." Handling this well means thinking in transactions, events, and explicit state machines rather than hoping everything works out.
Models: Order, OrderItem, and Payment
We start with the domain. An Order contains multiple OrderItems (each representing a product line), and a Payment that tracks the financial side. Relationships matter here—they're not just convenience, they're how we enforce data integrity.
// app/Models/Order.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Order extends Model
{
protected $fillable = ['user_id', 'status', 'total_amount'];
protected $casts = [
'total_amount' => 'decimal:2',
];
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function payment(): HasOne
{
return $this->hasOne(Payment::class);
}
}// app/Models/OrderItem.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OrderItem extends Model
{
protected $fillable = ['order_id', 'product_id', 'quantity', 'unit_price'];
protected $casts = [
'unit_price' => 'decimal:2',
];
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}// app/Models/Payment.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Payment extends Model
{
protected $fillable = ['order_id', 'amount', 'status', 'transaction_id'];
protected $casts = [
'amount' => 'decimal:2',
];
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
}Notice the explicit status fields and casts. We'll use status to track the order lifecycle—pending, confirmed, shipped, etc. The Payment model gives us an audit trail: even if a charge fails, we have a record of the attempt.
The PlaceOrder Action
This is where the magic happens. We validate stock, create records atomically, and fail cleanly if something's wrong. The Action pattern keeps this logic testable and reusable whether called from a controller, API endpoint, or background job.
// app/Actions/PlaceOrder.php
namespace App\Actions;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
class PlaceOrder
{
/**
* @param int $userId
* @param array $items [['product_id' => 1, 'quantity' => 2], ...]
* @throws InvalidArgumentException
* @return Order
*/
public function execute(int $userId, array $items): Order
{
// Validate stock for all items first
foreach ($items as $item) {
$product = Product::findOrFail($item['product_id']);
if ($product->stock < $item['quantity']) {
throw new InvalidArgumentException(
"Product {$product->name} only has {$product->stock} left"
);
}
}
// Atomic transaction: create order, items, deduct stock
return DB::transaction(function () use ($userId, $items) {
$totalAmount = 0;
$order = Order::create([
'user_id' => $userId,
'status' => 'pending',
'total_amount' => 0, // We'll update this
]);
foreach ($items as $item) {
$product = Product::findOrFail($item['product_id']);
$lineTotal = $product->price * $item['quantity'];
$totalAmount += $lineTotal;
$order->items()->create([
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'unit_price' => $product->price,
]);
// Deduct stock
$product->decrement('stock', $item['quantity']);
}
// Update order total
$order->update(['total_amount' => $totalAmount]);
return $order;
});
}
}The key here is the transaction wrapper. If anything fails—a product disappears, stock goes negative despite our check—the entire operation rolls back. No orphaned orders, no double-deductions.
Events and Queued Listeners
Once an order is placed, we need to trigger side effects: send a confirmation email, generate an invoice, notify the warehouse. We do this via events and listeners, and queue them so the HTTP response isn't blocked.
// app/Events/OrderPlaced.php
namespace App\Events;
use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class OrderPlaced
{
use Dispatchable, SerializesModels;
public function __construct(public Order $order)
{
}
}Now we dispatch this event after the order is created. In your controller or action, after the transaction succeeds:
// Within your controller or action, after PlaceOrder executes
$order = (new PlaceOrder())->execute($userId, $items);
OrderPlaced::dispatch($order);
return response()->json(['order_id' => $order->id], 201);The listeners handle the async work:
// app/Listeners/SendOrderConfirmationEmail.php
namespace App\Listeners;
use App\Events\OrderPlaced;
use App\Mail\OrderConfirmation;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;
class SendOrderConfirmationEmail implements ShouldQueue
{
public function handle(OrderPlaced $event): void
{
Mail::to($event->order->user->email)
->send(new OrderConfirmation($event->order));
}
}// app/Listeners/GenerateInvoice.php
namespace App\Listeners;
use App\Events\OrderPlaced;
use App\Jobs\GenerateInvoicePdf;
use Illuminate\Contracts\Queue\ShouldQueue;
class GenerateInvoice implements ShouldQueue
{
public function handle(OrderPlaced $event): void
{
GenerateInvoicePdf::dispatch($event->order);
}
}By implementing ShouldQueue, these listeners are pushed to your queue (Redis, database, whatever you're using) and processed by a worker. Your API returns immediately; emails go out moments later.
Feature Tests: Happy Path and Failures
Testing orders is where you catch the subtle bugs. We test the happy path and, crucially, the failure cases.
// tests/Feature/PlaceOrderTest.php
namespace Tests\Feature;
use App\Actions\PlaceOrder;
use App\Events\OrderPlaced;
use App\Models\Order;
use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use InvalidArgumentException;
use Tests\TestCase;
class PlaceOrderTest extends TestCase
{
use RefreshDatabase;
public function test_can_place_order_with_valid_items()
{
Event::fake();
$user = User::factory()->create();
$product = Product::factory()->create(['stock' => 10, 'price' => 29.99]);
$order = (new PlaceOrder())->execute($user->id, [
['product_id' => $product->id, 'quantity' => 2],
]);
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'user_id' => $user->id,
'status' => 'pending',
'total_amount' => 59.98,
]);
$this->assertDatabaseHas('order_items', [
'order_id' => $order->id,
'product_id' => $product->id,
'quantity' => 2,
'unit_price' => 29.99,
]);
$this->assertEquals(8, $product->fresh()->stock);
Event::assertDispatched(OrderPlaced::class);
}
public function test_fails_when_product_stock_insufficient()
{
$user = User::factory()->create();
$product = Product::factory()->create(['stock' => 2, 'price' => 29.99]);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('only has 2 left');
(new PlaceOrder())->execute($user->id, [
['product_id' => $product->id, 'quantity' => 5],
]);
// Verify no order was created
$this->assertDatabaseMissing('orders', ['user_id' => $user->id]);
}
public function test_rollback_on_concurrent_stock_depletion()
{
// Simulate another order taking the last items
$user = User::factory()->create();
$product = Product::factory()->create(['stock' => 3, 'price' => 10.00]);
$this->expectException(InvalidArgumentException::class);
(new PlaceOrder())->execute($user->id, [
['product_id' => $product->id, 'quantity' => 3],
]);
}
}These tests verify that the order system enforces constraints and rolls back cleanly. The event dispatch check ensures our notifications are being triggered.
Common mistakes
Not using transactions: We've seen orders created with items but no stock deduction, or worse, stock deducted twice. Always wrap multi-step operations in a transaction.
Checking stock, then creating (race condition): Between your check and your deduction, another request might succeed. That's why we validate first, then do everything in one atomic transaction.
Synchronous side effects: If you send emails or call external services inline, a timeout will fail the whole request. Queue them. Always.
Forgetting to test failures: The happy path is boring. Test what breaks: insufficient stock, invalid products, concurrent orders. That's where bugs live.
Key takeaways
- Model relationships explicitly; use casts for monetary fields to avoid floating-point surprises.
- Encapsulate order creation in an Action class with validation and atomic transactions.
- Dispatch domain events after success; implement listeners with
ShouldQueueto keep responses fast. - Test both the happy path and failure cases; verify transactions roll back correctly.
- Stock validation happens before the transaction; deduction happens inside it. Never check, then act.
- Always use database transactions for multi-step operations that touch multiple tables.






Comments
Loading comments…