Skip to main content

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

inventory_item_id
integer
required
Foreign key to the inventory item being adjusted
company_id
integer
required
Foreign key to the company (multi-tenant isolation)
user_id
integer
required
User who performed the stock adjustment
movement_type
string
required
Type of movement: addition, sale, adjustment_in, adjustment_out, return
quantity
integer
required
Quantity changed (positive for increases, negative for decreases)
stock_before
integer
required
Stock level before this movement
stock_after
integer
required
Stock level after this movement
notes
string
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

TypeDescriptionQuantity SignUse Case
additionStock added to inventoryPositiveReceiving new shipments
saleStock sold to customerNegativeBilling a product
adjustment_inManual increasePositiveCorrecting count errors (found more)
adjustment_outManual decreaseNegativeCorrecting count errors (found less)
returnCustomer returnPositiveProduct 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)
);

Build docs developers (and LLMs) love