La gestion de stock, ce n’est pas juste des entrées et des sorties. Dès qu’on passe en conditions réelles, on se retrouve vite avec des questions très concrètes : pourquoi le stock théorique ne correspond pas au stock réel, qui a fait ce mouvement, où est passée la marge, pourquoi une commande est en retard.

Dans cet article, je te partage ce mini projet avec le code source que tu peux télécharger sur notre dépot GitHub via le lien ci-dessous. On va voir comment il est structuré, comment l’installer sur WAMP/XAMP, comment la base de données est pensée, et surtout comment les flux métier fonctionnent vraiment : mouvements de stock, inventaires, commandes d’achat, alertes, rôles et sécurité.

Gestion Stock Pro n’a pas été conçue comme un simple projet CRUD destiné à afficher et modifier des données. Elle a été pensée comme une véritable application métier, capable de gérer un cycle logistique complet, avec des règles cohérentes et une séparation claire des responsabilités.

Concrètement, l’application couvre plusieurs domaines essentiels d’un système de gestion de stock moderne.

Elle gère d’abord le référentiel : produits, catégories, fournisseurs, clients, unités, taxes, tags et autres entités nécessaires à une base structurée et exploitable. Ce socle permet d’organiser l’information proprement avant même de parler de mouvements ou de commandes.

Elle prend ensuite en charge le stock multi-entrepôts, avec une distinction claire entre :

  • les niveaux de stock courants (état),
  • et le journal des mouvements (historique).

Cette séparation permet à la fois d’avoir une vision instantanée des quantités disponibles et une traçabilité complète des variations.

L’application gère les mouvements de stock selon quatre types métier :

  • entrée (IN),
  • sortie (OUT),
  • transfert inter-entrepôts (TRANSFER),
  • ajustement (ADJUSTMENT).

Chaque mouvement est contrôlé (impossible de descendre en négatif sans validation), historisé et exploitable dans les rapports.

Côté approvisionnement, elle intègre un module achats complet : création de commandes fournisseurs, gestion des statuts et réceptions partielles, avec génération automatique des entrées en stock lors de la réception.

Les inventaires sont également pris en charge via des sessions dédiées, permettant de comparer le stock théorique au stock réel et de générer les ajustements nécessaires.

Le système inclut aussi des alertes automatiques basées sur les seuils de réapprovisionnement (stock bas / rupture), afin d’anticiper les besoins.

Pour accélérer l’intégration initiale ou les migrations de données, des imports CSV multi-entités sont disponibles (produits, fournisseurs, stocks initiaux, etc.). À l’inverse, des exports CSV permettent de produire des rapports exploitables pour l’analyse, la comptabilité ou le pilotage.

L’ensemble repose sur une architecture claire :

  • un back-end API REST JSON, développé sans framework lourd, avec router et middlewares internes,
  • une séparation nette des couches (Domain, Application, Infrastructure, Presentation),
  • un frontend découplé qui consomme uniquement l’API via HTTP.

Ce choix permet :

  • une meilleure maintenabilité,
  • une évolutivité progressive,
  • une adaptation possible vers un framework comme Laravel si nécessaire,
  • ou une migration vers une architecture plus distribuée à long terme.
gestion-stock/
  backend/
    bin/
    config/
    public/
    src/
  config/
  database/
    migrations/
    seeders/
  frontend/
    assets/
    auth/

Rôle des dossiers

  • backend/public/index.php : point d’entrée unique de l’API (/api/v1/...)
  • backend/src/Presentation : controllers HTTP + middlewares (auth/roles)
  • backend/src/Application : services métier (stock, achats, inventaires…)
  • backend/src/Domain : interfaces / contrats (repositories)
  • backend/src/Infrastructure : accès DB via repositories PDO + persistence
  • backend/src/Shared : router maison, Request/Response, sécurité, DB…
  • database/migrations : schéma MySQL versionné (up/down)
  • database/seeders : données de démarrage (comptes, rôles, exemples)
  • frontend/ : UI (pages + JS), qui consomme exclusivement l’API
  • frontend/assets/js/app-clean.js : logique UI et modules CRUD (métadonnées)

frontend/auth/ existe mais est vide dans l’état actuel (probablement réservé pour une évolution future : reset mdp, etc.).

  • PHP 8+
  • Pas de framework type Laravel/Symfony : routing + architecture interne “maison”
  • API REST JSON
  • Auth : Bearer token (token côté client, hash SHA-256 en base)
  • Mots de passe : hash Argon2id
  • Pages PHP (pour servir l’UI)
  • JS vanilla (login + client HTTP + app-clean)
  • Chart.js + Bootstrap Icons (via CDN)
  • Approche : front découplé = plus proche d’une vraie app moderne
  • MySQL via PDO
  • Migrations + seeders maison
  • WAMP installé (Apache + MySQL)
  • PHP CLI disponible : php -v
  • MySQL démarré (WAMP “vert”)
  • Projet dans : C:\wamp64\www\gestion-stock

Édite config/database.php :

return [
    'host' => getenv('DB_HOST') ?: '127.0.0.1',
    'port' => (int)(getenv('DB_PORT') ?: 3306),
    'name' => getenv('DB_NAME') ?: 'gestion_stock',
    'username' => getenv('DB_USER') ?: 'root',
    'password' => getenv('DB_PASS') ?: '',
    'charset' => getenv('DB_CHARSET') ?: 'utf8mb4',
];

Depuis la racine :

php backend/bin/migrate.php up
php backend/bin/seed.php
  • Login : http://localhost/gestion-stock/frontend/login.php
  • Dashboard : http://localhost/gestion-stock/frontend/index.php
  • Health API : http://localhost/gestion-stock/backend/public/api/v1/health
Compte de test (seed)

Utilisez les identifiants suivants pour vous connecter:

Source : backend/public/index.php

$appConfig = require dirname(__DIR__) . '/config/app.php';

// CORS simple: on autorise uniquement les origines configurees.
$origin = $_SERVER['HTTP_ORIGIN'] ?? '*';
$allowedOrigins = $appConfig['cors_allowed_origins'];
if ($origin !== '*' && in_array($origin, $allowedOrigins, true)) {
    header('Access-Control-Allow-Origin: ' . $origin);
} elseif ($allowedOrigins !== []) {
    header('Access-Control-Allow-Origin: ' . $allowedOrigins[0]);
}
header('Vary: Origin');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');

if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') {
    http_response_code(204);
    exit;
}

$request = Request::fromGlobals();

$authMiddleware = new AuthMiddleware($authService);
$adminRolesMiddleware = new RoleMiddleware(['SUPER_ADMIN', 'ADMIN']);
$stockRolesMiddleware = new RoleMiddleware(['SUPER_ADMIN', 'ADMIN', 'STOREKEEPER', 'MANAGER']);
$buyRolesMiddleware = new RoleMiddleware(['SUPER_ADMIN', 'ADMIN', 'BUYER', 'MANAGER']);

$router = new Router();
$router->add('POST', '/api/v1/auth/login', static fn (Request $req) => $authController->login($req));
$router->add('GET', '/api/v1/auth/me', static fn (Request $req) => $authController->me($req), [$authMiddleware]);
$router->add('GET', '/api/v1/dashboard/stats', static fn () => $dashboardController->stats(), [$authMiddleware]);

Migrations :

  • database/migrations/up/202602270001_initial_stock_schema.sql
  • database/migrations/up/202602270002_advanced_inventory_procurement.sql
  • database/migrations/up/202602270003_test_fields_pack.sql

Auth / gouvernance

  • users, roles, personal_access_tokens, audits, app_settings

Référentiel

  • products, categories, suppliers, customers, brands, units, taxes, tags

Stock

  • stock_levels (état du stock)
  • stock_movements (historique/journal)
    • gestion entrepôts/emplacements : warehouses, warehouse_zones, warehouse_locations

Achats

  • purchase_requests, purchase_orders + leurs items

Inventaires

  • inventory_sessions, inventory_session_items, inventory_adjustments

Source : database/migrations/up/202602270001_initial_stock_schema.sql

CREATE TABLE IF NOT EXISTS products (
    id INT AUTO_INCREMENT PRIMARY KEY,
    sku VARCHAR(80) NOT NULL UNIQUE,
    name VARCHAR(180) NOT NULL,
    description TEXT NULL,
    category_id INT NOT NULL,
    supplier_id INT NULL,
    unit_price DECIMAL(14,2) NOT NULL DEFAULT 0,
    cost_price DECIMAL(14,2) NOT NULL DEFAULT 0,
    reorder_level INT NOT NULL DEFAULT 10,
    status ENUM('ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'ACTIVE',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    CONSTRAINT fk_products_category FOREIGN KEY (category_id) REFERENCES categories (id),
    CONSTRAINT fk_products_supplier FOREIGN KEY (supplier_id) REFERENCES suppliers (id) ON DELETE SET NULL
) ENGINE=InnoDB;

CREATE TABLE IF NOT EXISTS stock_levels (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    product_id INT NOT NULL,
    warehouse_id INT NOT NULL,
    quantity INT NOT NULL DEFAULT 0,
    reserved_quantity INT NOT NULL DEFAULT 0,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uq_stock_level (product_id, warehouse_id),
    CONSTRAINT fk_stock_levels_product FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE,
    CONSTRAINT fk_stock_levels_warehouse FOREIGN KEY (warehouse_id) REFERENCES warehouses (id) ON DELETE CASCADE
) ENGINE=InnoDB;

Service : backend/src/Application/Services/StockService.php

$productId = (int)($payload['product_id'] ?? 0);
$warehouseId = (int)($payload['warehouse_id'] ?? 0);
$type = strtoupper((string)($payload['type'] ?? ''));
$quantity = (int)($payload['quantity'] ?? 0);
$destinationWarehouseId = isset($payload['destination_warehouse_id']) ? (int)$payload['destination_warehouse_id'] : null;

if ($productId <= 0 || $warehouseId <= 0 || $quantity <= 0 || !in_array($type, ['IN', 'OUT', 'ADJUSTMENT', 'TRANSFER'], true)) {
    throw new HttpException('Invalid stock movement payload', 422);
}

$current = $this->productRepository->stockLevel($productId, $warehouseId);
$currentQty = $current['quantity'] ?? 0;
$nextQty = $currentQty;

if ($type === 'IN') {
    $nextQty = $currentQty + $quantity;
} elseif ($type === 'OUT') {
    $nextQty = $currentQty - $quantity;
    if ($nextQty < 0) {
        throw new HttpException('Insufficient stock', 422);
    }
} elseif ($type === 'ADJUSTMENT') {
    $nextQty = $quantity;
} else {
    if (!$destinationWarehouseId || $destinationWarehouseId === $warehouseId) {
        throw new HttpException('A valid destination warehouse is required for transfer', 422);
    }

    $nextQty = $currentQty - $quantity;
    if ($nextQty < 0) {
        throw new HttpException('Insufficient stock for transfer', 422);
    }

    $destinationCurrent = $this->productRepository->stockLevel($productId, $destinationWarehouseId);
    $destinationQty = ($destinationCurrent['quantity'] ?? 0) + $quantity;
    $this->productRepository->upsertStockLevel($productId, $destinationWarehouseId, $destinationQty);
}

Service : backend/src/Application/Services/PurchaseOrderService.php

if (in_array($order['status'], ['CANCELLED', 'RECEIVED'], true)) {
    throw new HttpException('Purchase order cannot be received in current status', 422);
}

$items = $payload['items'] ?? [];
if (!is_array($items) || $items === []) {
    throw new HttpException('At least one received item is required', 422);
}

foreach ($items as $line) {
    $itemId = (int)($line['item_id'] ?? 0);
    $receivedQty = (int)($line['quantity_received'] ?? 0);

    if ($itemId <= 0 || $receivedQty <= 0) {
        throw new HttpException('Invalid received line payload', 422);
    }

    $orderItem = $indexedItems[$itemId];
    $remaining = (int)$orderItem['quantity_ordered'] - (int)$orderItem['quantity_received'];
    if ($receivedQty > $remaining) {
        throw new HttpException("Received quantity exceeds remaining qty for item {$itemId}", 422);
    }

    $this->stockService->createMovement([
        'product_id' => (int)$orderItem['product_id'],
        'warehouse_id' => (int)$order['warehouse_id'],
        'type' => 'IN',
        'quantity' => $receivedQty,
        'reason_code' => 'PO_RECEIPT',
        'reference_type' => 'PURCHASE_ORDER',
        'reference_id' => $id,
        'notes' => 'PO receipt ' . (string)$order['order_number'],
    ], $actorId, $ip);

    $this->repository->receiveItemQuantity($id, $itemId, $receivedQty);
}

$newStatus = $this->repository->syncOrderStatusFromItems($id);
  • endpoint : /api/v1/auth/login
  • token bearer : stocké côté client, hashé en base (SHA-256)
  • mot de passe : Argon2id

Rôles seedés : ADMIN, MANAGER, STOREKEEPER, EMPLOYEE, SUPER_ADMIN, BUYER, VIEWER.

Middleware :

  • AuthMiddleware : 401 si pas de token
  • RoleMiddleware : 403 si rôle non autorisé
public function __invoke(Request $request, array $params, callable $next): void
{
    $token = $request->bearerToken();
    if (!$token) {
        throw new HttpException('Missing bearer token', 401);
    }

    $user = $this->authService->resolveUserByToken($token);
    $request->setAttribute('auth_user', $user);
    $request->setAttribute('auth_token', $token);

    $next($request, $params);
}

public function __invoke(Request $request, array $params, callable $next): void
{
    $user = $request->attribute('auth_user');
    $role = strtoupper((string)($user['role_code'] ?? ''));

    if (!in_array($role, $this->allowedRoles, true)) {
        throw new HttpException('Forbidden', 403);
    }

    $next($request, $params);
}

Le front est piloté par :

  • frontend/index.php
  • frontend/assets/js/app-clean.js
  • frontend/assets/js/http-client.js
  • frontend/assets/js/login.js
  • Login
  • Dashboard
  • Produits
  • Catégories
  • Fournisseurs
  • Mouvements
  • Inventaires
  • Alertes
  • Demandes d’achat
  • Commandes d’achat
  • Imports CSV
  • Rapports
  • Utilisateurs / Paramètres (admin)

Modules CRUD configurés par métadonnées

Source : frontend/assets/js/app-clean.js

const moduleTitles = {
    dashboard: 'Tableau de bord',
    products: 'Produits',
    categories: 'Categories',
    suppliers: 'Fournisseurs',
    movements: 'Mouvements',
    inventories: 'Inventaires',
    alerts: 'Alertes',
    'purchase-requests': 'Demandes achat',
    'purchase-orders': 'Commandes achat',
    imports: 'Importations CSV',
    reports: 'Rapports',
};

const crudModules = {
    products: {
        endpoint: '/products',
        label: 'produit',
        fields: [
            { key: 'sku', label: 'SKU', type: 'text', required: true },
            { key: 'barcode', label: 'Code barre', type: 'text' },
            { key: 'name', label: 'Nom', type: 'text', required: true },
            { key: 'category_id', label: 'Categorie', type: 'select', optionsFrom: 'categories', optionLabel: 'name', required: true },
            { key: 'supplier_id', label: 'Fournisseur', type: 'select', optionsFrom: 'suppliers', optionLabel: 'name' },
            { key: 'unit_price', label: 'Prix vente', type: 'number', step: '0.01' },
            { key: 'cost_price', label: 'Prix achat', type: 'number', step: '0.01' },
            { key: 'reorder_level', label: 'Seuil alerte', type: 'number' },
            { key: 'valuation_method', label: 'Valorisation', type: 'select', options: [
                { value: 'CUMP', label: 'CUMP' },
                { value: 'FIFO', label: 'FIFO' },
            ] },
        ],
    },
};

Pour valider que l’application fonctionne correctement, voici quelques scénarios simples mais complets à exécuter.

Commence par créer un produit, puis effectue une entrée en stock (IN). Vérifie que le niveau de stock augmente correctement. Enchaîne avec une sortie (OUT) et assure-toi que la quantité diminue, puis teste une sortie supérieure au stock disponible : l’application doit refuser l’opération.

Crée ensuite une commande d’achat avec plusieurs lignes, puis réalise une réception partielle. Vérifie que les mouvements d’entrée sont générés automatiquement et que le statut de la commande évolue correctement.

Lance une session d’inventaire, modifie volontairement certaines quantités, puis finalise la session pour confirmer que les ajustements sont bien appliqués.

Enfin, teste un export CSV (stock ou mouvements) afin de vérifier la cohérence des données produites.

Si ces scénarios passent sans incohérence, la logique métier principale de l’application est validée.

Ce projet démontre qu’il est possible de construire une application métier solide en PHP/MySQL sans dépendre d’un framework lourd, tout en respectant des principes d’architecture propres et maintenables.

Ce projet ne se limite pas à afficher des données. Il intègre une logique métier réelle : gestion multi-entrepôts, journalisation des mouvements, réceptions partielles d’achats, sessions d’inventaire, alertes automatiques, imports et exports exploitables. L’ensemble repose sur une séparation claire des couches, une API REST cohérente et un contrôle d’accès structuré par rôles.

En l’état, l’application constitue une base robuste pour un MVP avancé ou un projet interne. Avec l’ajout de tests automatisés, d’un durcissement sécurité et d’un pipeline CI/CD, elle peut évoluer vers un environnement de production plus exigeant.

Si tu cherches à comprendre comment structurer une application métier proprement, ou si tu veux t’inspirer d’une architecture pragmatique en PHP, ce projet est un excellent point de départ.

Comments

No comments yet. Why don’t you start the discussion?

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *