Overview
The InventoryMovement model provides a complete audit trail of all stock changes for inventory items. Every stock adjustment creates a movement record showing what changed, who made the change, and the before/after stock levels.
Model Definition
namespace App\Models;
class InventoryMovement extends Model
{
protected $fillable = [
'inventory_item_id',
'company_id',
'user_id',
'movement_type',
'quantity',
'stock_before',
'stock_after',
'notes',
];
protected function casts(): array
{
return [
'quantity' => 'integer',
'stock_before' => 'integer',
'stock_after' => 'integer',
];
}
}
Attributes
Foreign key to the inventory item being adjusted
Foreign key to the company (multi-tenant isolation)
User who performed the stock adjustment
Type of movement: addition, sale, adjustment_in, adjustment_out, return
Quantity changed (positive for increases, negative for decreases)
Stock level before this movement
Stock level after this movement
Optional notes explaining the movement
Relationships
Inventory Item
public function inventoryItem(): BelongsTo
{
return $this->belongsTo(InventoryItem::class);
}
Access the item that was adjusted:
$movement = InventoryMovement::with('inventoryItem')->find(1);
$itemName = $movement->inventoryItem->name;
$currentStock = $movement->inventoryItem->stock;
Company
public function company(): BelongsTo
{
return $this->belongsTo(Company::class);
}
Movements are scoped to companies:
$movements = InventoryMovement::where('company_id', $companyId)->get();
User
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
Track who made the adjustment:
$movement = InventoryMovement::with('user')->find(1);
$userName = $movement->user->name;
$userEmail = $movement->user->email;
Movement Types
| Type | Description | Quantity Sign | Use Case |
|---|
addition | Stock added to inventory | Positive | Receiving new shipments |
sale | Stock sold to customer | Negative | Billing a product |
adjustment_in | Manual increase | Positive | Correcting count errors (found more) |
adjustment_out | Manual decrease | Negative | Correcting count errors (found less) |
return | Customer return | Positive | Product returned by customer |
Usage Examples
Recording a Stock Addition
use App\Models\InventoryMovement;
$movement = InventoryMovement::create([
'inventory_item_id' => $item->id,
'company_id' => $item->company_id,
'user_id' => auth()->id(),
'movement_type' => 'addition',
'quantity' => 50,
'stock_before' => $item->stock,
'stock_after' => $item->stock + 50,
'notes' => 'Received shipment from supplier',
]);
// Update the item stock
$item->increment('stock', 50);
Recording a Sale
$quantitySold = 3;
$movement = InventoryMovement::create([
'inventory_item_id' => $item->id,
'company_id' => $item->company_id,
'user_id' => auth()->id(),
'movement_type' => 'sale',
'quantity' => -$quantitySold,
'stock_before' => $item->stock,
'stock_after' => $item->stock - $quantitySold,
'notes' => 'Sold via billing document #' . $document->id,
]);
$item->decrement('stock', $quantitySold);
Recording a Manual Adjustment
// Physical count shows 5 fewer items than recorded
$discrepancy = -5;
$movement = InventoryMovement::create([
'inventory_item_id' => $item->id,
'company_id' => $item->company_id,
'user_id' => auth()->id(),
'movement_type' => $discrepancy > 0 ? 'adjustment_in' : 'adjustment_out',
'quantity' => $discrepancy,
'stock_before' => $item->stock,
'stock_after' => $item->stock + $discrepancy,
'notes' => 'Physical inventory count correction',
]);
$item->update(['stock' => $item->stock + $discrepancy]);
Querying Movement History
// Get all movements for an item
$movements = InventoryMovement::where('inventory_item_id', $itemId)
->orderBy('created_at', 'desc')
->with('user')
->get();
// Get recent sales
$recentSales = InventoryMovement::where('movement_type', 'sale')
->where('company_id', $companyId)
->whereBetween('created_at', [now()->subDays(30), now()])
->get();
// Get adjustments by a specific user
$userAdjustments = InventoryMovement::where('user_id', $userId)
->whereIn('movement_type', ['adjustment_in', 'adjustment_out'])
->get();
Automatic Movement Creation
The InventoryService automatically creates movements when stock changes:
// app/Services/InventoryService.php
public function adjustStock(InventoryItem $item, int $delta, string $reason): void
{
$movementType = match (true) {
$delta > 0 && str_contains(strtolower($reason), 'ajuste') => 'adjustment_in',
$delta < 0 && str_contains(strtolower($reason), 'ajuste') => 'adjustment_out',
$delta > 0 => 'addition',
default => 'adjustment_out',
};
InventoryMovement::create([
'inventory_item_id' => $item->id,
'company_id' => $item->company_id,
'user_id' => auth()->id(),
'movement_type' => $movementType,
'quantity' => $delta,
'stock_before' => $item->stock,
'stock_after' => $item->stock + $delta,
'notes' => $reason,
]);
$item->update(['stock' => $item->stock + $delta]);
}
Validation
Stock movements should always maintain data integrity:
// Verify stock_after = stock_before + quantity
$isValid = $movement->stock_after === ($movement->stock_before + $movement->quantity);
// Verify current stock matches last movement
$lastMovement = InventoryMovement::where('inventory_item_id', $itemId)
->latest()
->first();
if ($lastMovement && $item->stock !== $lastMovement->stock_after) {
// Stock mismatch detected
Log::warning("Stock mismatch for item {$itemId}");
}
Reporting & Analytics
Stock Movement Report
// Total units added in the last month
$totalAdded = InventoryMovement::where('company_id', $companyId)
->whereIn('movement_type', ['addition', 'adjustment_in', 'return'])
->whereBetween('created_at', [now()->subMonth(), now()])
->sum('quantity');
// Total units sold
$totalSold = InventoryMovement::where('company_id', $companyId)
->where('movement_type', 'sale')
->whereBetween('created_at', [now()->subMonth(), now()])
->sum('quantity');
Most Active Items
$mostActive = InventoryMovement::selectRaw('inventory_item_id, COUNT(*) as movement_count')
->where('company_id', $companyId)
->whereBetween('created_at', [now()->subDays(30), now()])
->groupBy('inventory_item_id')
->orderBy('movement_count', 'desc')
->limit(10)
->with('inventoryItem')
->get();
Best Practices
1. Never Delete Movements
Movements are audit records. Mark as void if needed:
// Don't do this
$movement->delete();
// Do this instead
$movement->update(['notes' => '[VOID] ' . $movement->notes]);
2. Always Record Who and When
InventoryMovement::create([
'user_id' => auth()->id(), // Always track who
'notes' => 'Reason for adjustment', // Always explain why
// ...
]);
3. Validate Stock Calculations
if ($stockBefore + $quantity !== $stockAfter) {
throw new \Exception('Stock calculation error');
}
4. Use Transactions
When updating stock and creating movements:
DB::transaction(function () use ($item, $quantity) {
InventoryMovement::create([...]);
$item->increment('stock', $quantity);
});
Database Schema
CREATE TABLE inventory_movements (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
inventory_item_id BIGINT UNSIGNED NOT NULL,
company_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
movement_type VARCHAR(255) NOT NULL,
quantity INT NOT NULL,
stock_before INT NOT NULL,
stock_after INT NOT NULL,
notes TEXT NULL,
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
FOREIGN KEY (inventory_item_id) REFERENCES inventory_items(id) ON DELETE CASCADE,
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT,
INDEX idx_item_created (inventory_item_id, created_at),
INDEX idx_company_type (company_id, movement_type)
);