To build a Laravel inventory management system that stays accurate, store stock as an append only ledger of movements instead of a number you overwrite, and wrap every write in a database transaction with a row lock. On hand quantity becomes the sum of that ledger, and every change carries a reason, a timestamp, and an actor.
Most tutorials skip that part. They hand you a products table with a stock integer, a controller that calls decrement(), and a styled table. It demos beautifully. It breaks the first time two orders for the last unit land in the same second, and it cannot answer the one question a warehouse manager always asks, which is why the count is wrong.
You already know the domain rules. Stock goes up when you receive, down when you ship, and sideways when someone counts a shelf and finds three fewer units than the system claims. This guide covers the schema, the Eloquent models, the concurrency safe write path, multi warehouse support, and the admin and reporting layer. Every section has code you can paste into a fresh Laravel 12 app.
Key TakeawaysRecord every change as a row in
stock_movementsand derive on hand quantity from the sum. A single mutable quantity column should never be your source of truth.Wrap each movement inDB::transaction()withlockForUpdate()on the stock level row, so two concurrent orders cannot both sell the last unit.Keep a denormalisedstock_levelstable with a unique index on product and warehouse, written inside the same transaction as the ledger, for fast reads.Splitquantity_on_handfromquantity_reservedso a cart hold never double sells inventory that has not shipped yet.
What a Laravel inventory management system actually needs
A working inventory system answers five questions at any moment, and a lone stock column answers none of them well.
How many units exist, broken down by warehouse, bin, or location.
How many can we actually sell, after subtracting units already promised to open orders.
Why the number changed, with a reason code, a source document, and the user who triggered it.
What the stock is worth, using weighted average or FIFO costing for the books.
What needs reordering, based on reorder points and supplier lead times.
Those requirements push you toward the design accountants settled on centuries ago. You record events, not balances. A sale does not set stock to 41. It writes a movement of negative one against product 7 in warehouse 2, referencing order 5192. The balance is a consequence of the events.
The payoff arrives during your first stock discrepancy. With a ledger you replay the movements and find the bad adjustment in under a minute. With a mutable column you have a number, no history, and an argument.
Scope matters too. A single store needs products, variants, and one location. A distributor needs transfers, purchase orders, and lot or serial tracking. Build the ledger first either way, because it is the piece you cannot retrofit cheaply. Everything else is CRUD and dashboards, the kind of internal tools work that quietly eats sprints. If you would rather start from a working skeleton, you can generate a Laravel app from a prompt and still own every line it writes.
Design the schema around stock movements not stock counts
The four core tables
The schema for a Laravel inventory management system comes down to four tables. Products describe what you sell, warehouses describe where it lives, stock_movements is the immutable ledger, and stock_levels is a cached balance you can index and join against.
Schema::create('stock_movements', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->foreignId('warehouse_id')->constrained();
$table->integer('quantity');
$table->string('type');
$table->nullableMorphs('reference');
$table->foreignId('user_id')->nullable()->constrained();
$table->timestamps();
$table->index(['product_id', 'warehouse_id', 'created_at']);
});
Schema::create('stock_levels', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->foreignId('warehouse_id')->constrained();
$table->integer('quantity_on_hand')->default(0);
$table->integer('quantity_reserved')->default(0);
$table->timestamps();
$table->unique(['product_id', 'warehouse_id']);
});Two details matter more than they look. The quantity column is signed, so receipts are positive, issues are negative, and the sum is always the balance. The reference morph ties every movement back to the document that caused it, so an agent can open order 5192 and see exactly which units left which shelf.
Why the ledger wins
Concern | Quantity column on products | Stock movement ledger |
|---|---|---|
History | None, the previous value is gone | Every change kept with reason and actor |
Concurrency | Lost updates unless you lock | Append only writes, lock the cached level |
Audit | Nothing to show an auditor | Full trail per product and warehouse |
Corrections | Overwrite and hope | Post a reversing adjustment |
Read speed | Fast | Fast with a cached levels table |
There is a real tradeoff here. Summing a ledger on every listing page gets slow past a few hundred thousand rows, which is why stock_levels exists. Treat it as a cache written in the same transaction as the movement, never as an independent truth. When the two disagree, the ledger is right and a rebuild command repairs the cache.
Model the relationships with Eloquent
Scaffold the models with factories and migrations in one pass. If the flags blur together, the php artisan make:model cheatsheet lists every one.
php artisan make:model Product -mf
php artisan make:model Warehouse -mf
php artisan make:model StockMovement -mf
php artisan make:model StockLevel -mfThe relationships stay boring, which is the point. A product has many movements and many levels, and the pivot between products and warehouses is the levels table itself.
class Product extends Model
{
public function movements(): HasMany
{
return $this->hasMany(StockMovement::class);
}
public function warehouses(): BelongsToMany
{
return $this->belongsToMany(Warehouse::class, 'stock_levels')
->withPivot(['quantity_on_hand', 'quantity_reserved']);
}
}
class StockMovement extends Model
{
protected $fillable = ['product_id', 'warehouse_id', 'quantity', 'type'];
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
public function reference(): MorphTo
{
return $this->morphTo();
}
}Using the levels table as a pivot gives you $product->warehouses with quantities attached, which covers most listing screens without a custom query. The Laravel relationship documentation explains the pivot and morph behaviour in depth. Add withSum where you genuinely need ledger totals, and lean on the cached level everywhere else.
Keep stock accurate under concurrent writes
A Laravel team running a small equipment rental business shipped their inventory module on a Friday. The write path passed code review. Two weeks later a flash promotion pushed 40 checkouts through in the same minute, and three customers bought the same last unit. Nothing errored. The stock column simply reached negative two, and the warehouse discovered it when picking failed. The fix was 11 lines, a transaction and a row lock. The refunds and apology emails took a week.
Lock the row then write the movement
This is the most important handful of lines in a Laravel inventory management system. Open a transaction, take a pessimistic lock on the level row, check availability, then write both the level update and the movement together.
public function record(int $productId, int $warehouseId, int $quantity, string $type): StockMovement
{
return DB::transaction(function () use ($productId, $warehouseId, $quantity, $type) {
$level = StockLevel::query()
->where('product_id', $productId)
->where('warehouse_id', $warehouseId)
->lockForUpdate()
->firstOr(fn () => StockLevel::create([
'product_id' => $productId,
'warehouse_id' => $warehouseId,
]));
if ($level->quantity_on_hand + $quantity < 0) {
throw new InsufficientStockException($productId, $warehouseId);
}
$level->increment('quantity_on_hand', $quantity);
return StockMovement::create([
'product_id' => $productId,
'warehouse_id' => $warehouseId,
'quantity' => $quantity,
'type' => $type,
]);
});
}The lock does the real work. Pessimistic locking holds the row until the transaction commits, so the second request waits, reads the updated balance, and fails cleanly rather than overselling. The unique index on product and warehouse covers the narrow race where two requests try to create the same level row at once.
Test the guarantee
Write this test before the controller that would let stock go negative.
it('refuses to sell more than is on hand', function () {
$product = Product::factory()->create();
$warehouse = Warehouse::factory()->create();
$stock = app(StockService::class);
$stock->record($product->id, $warehouse->id, 5, 'purchase');
expect(fn () => $stock->record($product->id, $warehouse->id, -6, 'sale'))
->toThrow(InsufficientStockException::class);
expect(StockLevel::first()->quantity_on_hand)->toBe(5);
});Add multi warehouse support and reservations
Multi warehouse comes free from the schema, because every movement already carries a warehouse_id. A transfer needs no special table. It is two movements inside one transaction, negative at the source and positive at the destination, both pointing at the same transfer record through the morph.
Track reserved stock separately
Reservations are where most inventory apps leak. A customer adds the last unit to a cart, checkout takes four minutes, and a second customer buys it meanwhile. Both saw availability because both read on hand quantity.
Hold two numbers on the level row instead. quantity_on_hand is what physically sits on the shelf and changes only when goods move. quantity_reserved is what has been promised but not shipped. Available stock is the difference, and that is the number your storefront should read.
Reserve on order creation, release on cancellation or expiry, and convert the reservation into a real negative movement when the pick is confirmed. A scheduled job that releases holds older than 15 minutes prevents more support tickets than almost anything else you will write. On an ecommerce build, wire that release job before launch rather than after the first oversell.
Ship the admin panel, reports, and alerts
Warehouse staff will not adopt custom Blade screens when a generated admin panel does the job better. Filament gives you resources, filters, and bulk actions over the same Eloquent models, and its official documentation covers tables and relation managers in detail. Build one resource per core table, then attach a read only relation manager for movements so nobody can quietly edit history.
Reporting is where the ledger pays you back a second time. Because every movement stores quantity, type, and a reference, three reports fall out of the same data with no extra modelling.
Stock valuation, by applying weighted average cost across receipt movements per product.
Movement history, a filtered ledger per product and location that doubles as the audit trail.
Reorder list, comparing available stock against reorder point and supplier lead time.
Automate low stock alerts
Schedule a command that compares available stock against reorder points, then dispatch a queued notification per responsible buyer. Sending through Laravel queues keeps a slow mail provider from blocking the scheduler, and it gives you retries when the provider throttles you. The same aggregates feed an analytics dashboard covering stock turn, dead stock, and shrinkage by location.
Generate a Laravel inventory management system with AI
Everything above the business rules in this build is mechanical. Migrations, models, factories, FormRequests, Policies, Filament resources, and the first round of Pest tests follow directly from the schema, and on a typical inventory project that layer consumes most of the calendar time. It is exactly the work AI code generation handles well.
A solo developer building a Laravel inventory app for a regional distributor spent most of week one on scaffolding before touching a single business rule. On the next project for a similar client, they generated that same layer from a written spec, then spent their hours on what was genuinely different, which was lot tracking and a two step transfer approval. Same output, most of the week back.
StackMivo targets that gap specifically. You describe the system in plain English and it produces production-ready Laravel apps as standard, ownable code, Eloquent models and relationships, migrations, controllers, Policies, FormRequests, API Resources, Filament resources, and Pest tests, then deploys to Laravel Cloud, Forge, Ploi, or your own SSH server with migrations running automatically. Connect an existing GitHub, GitLab, or Bitbucket repository and it indexes your models and routes first, so what it writes matches the conventions already in the project.
Be clear about what it does not remove. You still own the domain decisions, the ledger design, the locking strategy, and the reservation rules, because those are judgment calls rather than boilerplate. Read the generated migrations before you run them. Try StackMivo Now on a throwaway project and see how close the first pass lands on your schema.
Where to go from here
A Laravel inventory management system lives or dies on one decision, whether stock is an event log or a number. Choose the log. Derive balances from it, cache those balances in a table you can index, and guard every write with a transaction and a row lock so the last unit sells exactly once.
The rest follows in order. Warehouses ride along on the same movement rows, splitting on hand from reserved stops carts from overselling, Filament covers the warehouse team, and a scheduled queue job handles reorder alerts. Write the test that proves stock cannot go negative before you write the controller that would allow it.
Start with the two migrations from this guide on a fresh Laravel 12 app. A correct ledger and a passing concurrency test are an afternoon of work, and once that foundation holds, the remainder of the inventory app is just screens sitting on data you can trust.