appstract/laravel-stock
appstract/laravel-stock is a lightweight stock management package for Laravel. Add inventory tracking to Eloquent models with a simple trait, record stock mutations, and increase, decrease, or check available quantities. Ideal for simple product inventories and small shops.
Install and wire up (5 minutes). The package ships a service provider and a single migration for a stock_mutations ledger table.
composer require appstract/laravel-stock
php artisan vendor:publish --provider="Appstract\Stock\StockServiceProvider" --tag=migrations
php artisan migrate
Add the trait to any model that needs inventory:
namespace App\Models;
use Appstract\Stock\HasStock;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
use HasStock;
}
Where to look first. Read these three files before writing any code — the package is small and the whole contract lives here:
| File | Why |
|---|---|
src/HasStock.php |
The entire public API (addStock, subtractStock, setStock, getStock, mutation relation). This is the source of truth for your installed version. |
src/StockMutation.php |
The ledger row: stockable_id, stockable_type, quantity, reason, timestamps. |
database/migrations/*_create_stock_mutations_table.php |
Column types and indexes — check whether reason is nullable and whether the polymorphic index exists. |
First use case. Treat stock as a running balance: receiving goods adds, fulfilling an order subtracts, and the current level is read back from the model.
$product->addStock(25, 'initial delivery');
$product->subtractStock(2, 'order #1042');
$product->stock; // 23 (accessor on the trait)
$product->getStock(); // 23 (explicit method)
A realistic controller flow:
public function ship(Order $order)
{
DB::transaction(function () use ($order) {
$product = Product::lockForUpdate()->findOrFail($order->product_id);
if ($product->stock < $order->quantity) {
throw ValidationException::withMessages([
'stock' => 'Not enough stock to fulfil this order.',
]);
}
$product->subtractStock($order->quantity, "order:{$order->id}");
$order->update(['shipped_at' => now()]);
});
}
1. Treat the table as an append-only ledger, not a counter. Never update a mutation row or delete one to "fix" stock — add a compensating mutation. This gives you a free audit trail and makes reconciliation trivial:
$product->mutation(+10, 'stock count correction'); // if exposed in your version
// or: addStock/subtractStock with a reason string
2. Standardise reason strings. The column is free text, so invent a convention early (purchase:{po_id}, order:{order_id}, refund:{order_id}, count:{date}) and put it behind constants or an enum. Reporting later depends entirely on this discipline.
3. Wrap inventory writes in a domain action. Don't call subtractStock from controllers and jobs and listeners alike. One class, one place to add locking, validation, and events:
class AdjustStock
{
public function __invoke(Product $product, int $delta, string $reason): void
{
$delta >= 0
? $product->addStock($delta, $reason)
: $product->subtractStock(abs($delta), $reason);
}
}
4. Expose stock in API responses deliberately. The trait's accessor is not in $appends by default. Add it, or map it explicitly in a resource:
protected $appends = ['stock'];
// or, in ProductResource: 'stock' => $this->getStock(),
5. Eager-load history only when you need it. $product->stockMutations is a normal relation, so the usual N+1 rules apply. For a product list, prefer an aggregated query over loading mutations:
Product::withSum('stockMutations as stock_total', 'quantity')->get();
6. Emit your own events. The package is deliberately thin. Fire StockDepleted / LowStockReached from your AdjustStock action and listen for it to send alerts. Don't try to hook the package's internals.
7. Testing. Use RefreshDatabase; the migration is a normal package migration. Assert against the ledger rather than only the accessor — it catches double-writes:
$product->addStock(5, 'test');
$this->assertDatabaseHas('stock_mutations', [
'stockable_id' => $product->id,
'stockable_type' => Product::class,
'quantity' => 5,
]);
$this->assertSame(5, $product->getStock());
8. Refund / restock pattern. Mirror the original mutation with a positive entry referencing the same order id. This keeps SUM(quantity) correct without any special-case code.
No atomic decrement — race conditions are real. The package records mutations; it does not lock the parent row or enforce non-negative stock. Two concurrent checkouts can both read stock = 1 and both succeed. Always lockForUpdate() on the product inside a transaction, as shown above, and validate the balance yourself. If you skip this, you will ship oversold inventory.
Negative stock is allowed. subtractStock(10) on a product with 3 in stock happily writes -7. There is no guard, no exception, no validation rule. Add the check in your action class.
stock is computed, so you cannot orderBy('stock'). It isn't a column. Use withSum('stockMutations', 'quantity') or a subquery/join on the ledger, and alias the result. where('stock', '>', 0) will error or silently misbehave.
Performance degrades as the ledger grows. Every getStock() call is an aggregate over that model's rows. Fine for hundreds; painful for millions. Mitigations: an index on (stockable_type, stockable_id) (verify the published migration has one), a cached stock column updated in the same transaction, or periodic snapshots plus a since cutoff.
Maintenance risk. Last release was May 2021. Check it against your Laravel and PHP versions before committing — Laravel 10/11 and PHP 8.2+ may need a fork or a replace entry in composer.json. The package is small enough (~4 files) that vendoring or copying the trait into app/Support is a legitimate option and removes the upgrade risk entirely.
Orphaned mutations. The polymorphic relation has no cascade. Deleting a product leaves its stock_mutations rows behind and skews global reports. Clean up in a deleting observer, or add ->cascadeOnDelete() to a custom migration.
Writes outside the trait are invisible. A raw DB::table('products')->update(['stock' => ...]) or a seeder that sets a column bypasses the ledger and desynchronises the audit trail. Enforce the action class as the only writer, and grep for stock_mutations before shipping.
Quantity types. The column is an integer in the published migration. If you sell by weight or volume, you cannot use this as-is without altering the column to a decimal and auditing every comparison in your own code.
Debugging tips. When a balance looks wrong, dump the ledger ordered by created_at — it almost always shows a duplicate mutation from a retried job. Wrap your AdjustStock calls in idempotency (e.g. skip if a mutation with the same reason already exists for that model) if jobs can be retried.
Extension points. You can swap the mutation model by binding your own subclass in a service provider, override any trait method in your model, and hook Eloquent model events on StockMutation for cross-cutting concerns like broadcasting or audit logging. There is no config file — everything is convention-driven, which is pleasant until you need to change the table name.
How can I help you explore Laravel packages today?