Dans le [Module 1], nous avons mis en place un socle technique propre avec PHP 8.6, Laravel 13 et l’outillage de contrôle statique (Pint, Larastan).
L’erreur la plus fréquente avant de brancher une interface comme Filament consiste à générer des tables à la va-vite sans verrouiller le modèle de données. Pourtant, 80 % de la dette technique d’une application découle d’un schéma de base de données bancal : contraintes manquantes, absence d’index, valeurs magiques stockées en chaînes de caractères libres et absence de typage strict.
Dans ce module, nous concevons l’architecture métier d’EquipFlow en nous appuyant sur les Enums typés de PHP 8.6, des migrations rigoureuses et des factories capables de générer des jeux de données cohérents.
Le domaine métier d’EquipFlow
Notre outil doit répondre à trois exigences fonctionnelles précises :
- Les clients (clients) : Entreprises titulaires des contrats de mise à disposition.
- Le parc de matériel (equipment) : Ordinateurs, écrans ou serveurs identifiés par un numéro de série unique et rattachés à une catégorie stricte.
- Les affectations (assignments) : Contrats liant un équipement à un client pour une durée définie, avec un statut précis (actif, retour prévu, clôturé) et un coût mensuel.
Remplacer les valeurs magiques par des Enums PHP 8.6
L’usage de colonnes VARCHAR non contraintes pour stocker des états (« active« , « completed« ) est source de bugs et rend l’autocomplétion impossible. Nous créons deux Enums adossés à des chaînes (Backed Enums).
Catégories d’équipement : app/Enums/EquipmentCategory.php
declare(strict_types=1);
namespace App\Enums;
enum EquipmentCategory: string
{
case Laptop = 'laptop';
case Screen = 'screen';
case Server = 'server';
case Accessory = 'accessory';
public function label(): string
{
return match ($this) {
self::Laptop => 'Ordinateur portable',
self::Screen => 'Écran',
self::Server => 'Serveur / Réseau',
self::Accessory => 'Accessoire & Périphérique',
};
}
public function icon(): string
{
return match ($this) {
self::Laptop => 'heroicon-o-computer-desktop',
self::Screen => 'heroicon-o-tv',
self::Server => 'heroicon-o-server-stack',
self::Accessory => 'heroicon-o-device-phone-mobile',
};
}
}
Statuts d’affectation : app/Enums/AssignmentStatus.php
declare(strict_types=1);
namespace App\Enums;
enum AssignmentStatus: string
{
case Active = 'active';
case PendingReturn = 'pending_return';
case Completed = 'completed';
public function label(): string
{
return match ($this) {
self::Active => 'En service',
self::PendingReturn => 'Retour prévu',
self::Completed => 'Clôturé',
};
}
public function color(): string
{
return match ($this) {
self::Active => 'success',
self::PendingReturn => 'warning',
self::Completed => 'gray',
};
}
}
Ces méthodes label() et color() serviront directement à Filament dans le prochain module pour styliser les badges sans dupliquer de code.
Des migrations SQL blindées
Créons les fichiers de schéma :
php artisan make:migration create_clients_table
php artisan make:migration create_equipment_table
php artisan make:migration create_assignments_tableTable clients
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('clients', function (Blueprint $table) {
$table->id();
$table->string('name')->index();
$table->string('email')->unique();
$table->string('phone', 30)->nullable();
$table->string('vat_number', 50)->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('clients');
}
};
Table equipment
use App\Enums\EquipmentCategory;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('equipment', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('serial_number')->unique();
$table->string('category')->default(EquipmentCategory::Laptop->value);
$table->date('purchased_at')->nullable();
$table->timestamps();
$table->index(['category', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('equipment');
}
};Table assignments (Table relationnelle pivot)
use App\Enums\AssignmentStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('assignments', function (Blueprint $table) {
$table->id();
$table->foreignId('client_id')->constrained()->cascadeOnDelete();
$table->foreignId('equipment_id')->constrained()->cascadeOnDelete();
$table->date('assigned_at');
$table->date('returned_at')->nullable();
$table->string('status')->default(AssignmentStatus::Active->value);
$table->decimal('monthly_cost', 10, 2);
$table->text('notes')->nullable();
$table->timestamps();
// Index composite pour optimiser les filtres du tableau de bord
$table->index(['status', 'assigned_at']);
});
}
public function down(): void
{
Schema::dropIfExists('assignments');
}
};
Modèles Eloquent avec typage strict et Casts
Grâce au typage natif de PHP 8.6 et aux casts d’Eloquent, le framework convertit automatiquement les valeurs brutes de la base de données en objets fortement typés.
app/Models/Client.php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Client extends Model
{
use HasFactory;
protected $fillable = [
'name',
'email',
'phone',
'vat_number',
];
public function assignments(): HasMany
{
return $this->hasMany(Assignment::class);
}
}
app/Models/Equipment.php
declare(strict_types=1);
namespace App\Models;
use App\Enums\EquipmentCategory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Equipment extends Model
{
use HasFactory;
protected $fillable = [
'name',
'serial_number',
'category',
'purchased_at',
];
protected $casts = [
'category' => EquipmentCategory::class,
'purchased_at' => 'date',
];
public function assignments(): HasMany
{
return $this->hasMany(Assignment::class);
}
}
app/Models/Assignment.php
declare(strict_types=1);
namespace App\Models;
use App\Enums\AssignmentStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Assignment extends Model
{
use HasFactory;
protected $fillable = [
'client_id',
'equipment_id',
'assigned_at',
'returned_at',
'status',
'monthly_cost',
'notes',
];
protected $casts = [
'status' => AssignmentStatus::class,
'assigned_at' => 'date',
'returned_at' => 'date',
'monthly_cost' => 'decimal:2',
];
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function equipment(): BelongsTo
{
return $this->belongsTo(Equipment::class);
}
}
Factories intelligentes pour peupler l’environnement
Pour concevoir des écrans d’administration réalistes dès le Module 3, il nous faut des données variées.
Générons les factories :
php artisan make:factory ClientFactory
php artisan make:factory EquipmentFactory
php artisan make:factory AssignmentFactory
database/factories/ClientFactory.php
namespace Database\Factories;
use App\Models\Client;
use Illuminate\Database\Eloquent\Factories\Factory;
class ClientFactory extends Factory
{
protected $model = Client::class;
public function definition(): array
{
return [
'name' => fake()->company(),
'email' => fake()->unique()->companyEmail(),
'phone' => fake()->phoneNumber(),
'vat_number' => 'FR' . fake()->numerify('###########'),
];
}
}
database/factories/EquipmentFactory.php
namespace Database\Factories;
use App\Enums\EquipmentCategory;
use App\Models\Equipment;
use Illuminate\Database\Eloquent\Factories\Factory;
class EquipmentFactory extends Factory
{
protected $model = Equipment::class;
public function definition(): array
{
$models = [
'MacBook Pro 16" M3 Max',
'Dell XPS 15 9530',
'ThinkPad X1 Carbon Gen 12',
'Écran Dell UltraSharp 32" 4K',
'Serveur Synology NAS 8 Baies',
];
return [
'name' => fake()->randomElement($models),
'serial_number' => strtoupper(fake()->bothify('SN-####-????-2026')),
'category' => fake()->randomElement(EquipmentCategory::cases()),
'purchased_at' => fake()->dateTimeBetween('-2 years', 'now'),
];
}
}
database/factories/AssignmentFactory.php
namespace Database\Factories;
use App\Enums\AssignmentStatus;
use App\Models\Assignment;
use App\Models\Client;
use App\Models\Equipment;
use Illuminate\Database\Eloquent\Factories\Factory;
class AssignmentFactory extends Factory
{
protected $model = Assignment::class;
public function definition(): array
{
$status = fake()->randomElement(AssignmentStatus::cases());
$assignedAt = fake()->dateTimeBetween('-1 year', '-1 month');
$returnedAt = ($status === AssignmentStatus::Completed)
? fake()->dateTimeBetween($assignedAt, 'now')
: null;
return [
'client_id' => Client::factory(),
'equipment_id' => Equipment::factory(),
'assigned_at' => $assignedAt,
'returned_at' => $returnedAt,
'status' => $status,
'monthly_cost' => fake()->randomElement([45.00, 89.00, 125.00, 210.00]),
'notes' => fake()->optional(0.4)->sentence(),
];
}
}
Exécution du Seeder global
Dans database/seeders/DatabaseSeeder.php, orchestrez la génération :
namespace Database\Seeders;
use App\Models\Assignment;
use App\Models\Client;
use App\Models\Equipment;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
User::firstOrCreate(
['email' => 'admin@equipflow.test'],
[
'name' => 'Admin User',
'password' => Hash::make('password'),
]
);
$clients = Client::factory()->count(10)->create();
$equipments = Equipment::factory()->count(30)->create();
// Création de 20 assignations réparties
foreach (range(1, 20) as $index) {
Assignment::factory()->create([
'client_id' => $clients->random()->id,
'equipment_id' => $equipments->random()->id,
]);
}
}
}
Réinitialisez la base de données et peuplez-la en une seule commande :
php artisan migrate:fresh --seed
Lancez ensuite l’analyse statique configurée dans le Module 1 :
composer run analyse
Larastan valide l’ensemble : zéro erreur de typage, modèles alignés avec la base de données.
Résumé du Module 2
Notre domaine est désormais sain et inviolable :
- Les index SQL sont en place sur les colonnes de recherche et de tri fréquent.
- Les relations Eloquent sont typées de bout en bout.
- Les factories alimentent une base de données de test représentative.
Dans le Module 3, nous attaquerons le panneau Filament 5 : génération des premières interfaces d’administration, formulaires réactifs avec validation asynchrone et tables interactives.



