Quand on a besoin d'attacher des fichiers à nos modèles Eloquent, on pense souvent à des packages comme Spatie MediaLibrary. Le problème, c'est que ces extensions créent une table séparée pour gérer les médias. Dans certaines situations, on veut quelque chose de plus simple : sauvegarder directement le nom du fichier dans la table du modèle. Dans cet article, on va voir comment créer un trait HasMedia qui permet de gérer cela de manière élégante.
Sommaire
- 00:00 Introduction
- 02:35 Création des tests
- 10:21 Implémentation de base
- 19:10 Gestion du remplacement (update)
- 24:20 Gestion de la suppression du model (delete)
- 29:25 Erreur d'écritue
- 31:45 Namer dynamique
- 36:56 Gestion de la création (create)
- 48:34 Gestion des URLs
L'objectif
L'idée est de pouvoir attacher des fichiers à nos modèles de manière simple. Prenons un exemple concret : un système de candidature où les utilisateurs peuvent uploader leur CV. Au niveau de la base de données, on a simplement un champ cv qui contient le nom du fichier.
Voici ce qu'on veut obtenir au niveau de notre modèle :
class JobApplication extends Model
{
use HasMedia;
protected static function booted(): void
{
static::registerMediaForProperty(
property: 'cv',
directory: 'documents',
filename: fn (JobApplication $model) => $model->id . '-' . Str::random(16)
);
}
}
Et côté contrôleur, l'utilisation sera tout aussi simple :
$application->attachMedia('cv', $request->file('cv'));
Le système se chargera automatiquement de supprimer l'ancien fichier lors d'une mise à jour, et de nettoyer les fichiers lors de la suppression du modèle.
La stratégie
On va utiliser un trait pour plusieurs raisons :
- Ça permet d'enregistrer des événements (comme la suppression du modèle)
- Ça offre des méthodes utiles directement sur le modèle (
attachMedia,detachMedia,mediaUrl) - C'est facilement réutilisable sur n'importe quel modèle
La structure sera la suivante :
app/
└── Concerns/
└── Media/
├── HasMedia.php
└── MediaMapping.php
Tester
Pour commencer on peut créer des tests (avec Pest). Voici un exemple du premier test pour découvrir la structure :
beforeEach(function () {
\Illuminate\Support\Facades\Storage::fake('public');
\Illuminate\Support\Facades\Schema::create('media_test', function ($table) {
$table->id();
$table->string('name')->nullable();
$table->string('namewithid')->nullable();
$table->string('slug')->nullable();
$table->timestamps();
});
\Illuminate\Support\Str::createRandomStringsUsing(fn () => 'aaa');
});
afterEach(function () {
\Illuminate\Support\Facades\Schema::dropIfExists('media_test');
\Illuminate\Support\Str::createRandomStringsNormally();
});
class TestModel extends \Illuminate\Database\Eloquent\Model {
use \App\Concerns\Media\HasMedia;
protected $table = 'media_test';
protected $guarded = [];
protected static function booted()
{
self::registerMediaForProperty(
property: 'name',
directory: 'documents',
filename: 'slug'
);
self::registerMediaForProperty(
property: 'namewithid',
directory: 'documents',
filename: fn ($model) => $model->id . '-' . \Illuminate\Support\Str::random(16),
);
}
}
it('should attach media correctly', function () {
$model = new TestModel();
$model->slug = 'demo';
$model->save();
$model->attachMedia(\Illuminate\Http\UploadedFile::fake()->create('cv.pdf', 100), 'name');
expect($model->name)->toBe('demo.pdf');
\Illuminate\Support\Facades\Storage::disk('public')->assertExists('documents/demo.pdf');
});
Implémentation
L'enregistrement du mapping
Au niveau du trait, on maintient un tableau statique $mediaMapping qui associe chaque propriété du modèle à sa configuration. La méthode registerMediaForProperty() permet de remplir ce tableau :
protected static array $mediaMapping = [];
public static function registerMediaForProperty(
string $property,
string $directory,
string|\Closure $filename,
string $disk = 'public',
): void {
static::$mediaMapping[$property] = new MediaMapping(
directory: $directory,
filename: $filename,
disk: $disk,
);
}
Cette méthode est appelée dans le booted() du modèle. On utilise des paramètres nommés pour rendre l'appel plus lisible et éviter les erreurs d'ordre des arguments.
La classe MediaMapping
La classe MediaMapping qui permettra de représenter la configuration d'un média et stockera trois informations clefs :
- le dossier de destination
- la logique de génération du nom de fichier (qui peut être une chaîne simple ou une closure)
- le disque de stockage à utiliser (public par défaut)
Gestion de la suppression
Laravel offre une convention pratique : si un trait définit une méthode boot{NomDuTrait}, elle sera automatiquement appelée au démarrage du modèle. On en profite pour enregistrer un listener sur l'événement deleting pour la gestion de la suppression des fichiers attachés :
protected static function bootHasMedia(): void
{
static::deleting(function ($model) {
foreach (static::$mediaMapping as $property => $mapping) {
$model->detachMedia($property);
}
});
}
Ainsi, quand un modèle est supprimé, tous ses fichiers associés sont automatiquement nettoyés. Pas besoin d'y penser dans le contrôleur.
Le principe de attachMedia
La méthode attachMedia() est le cœur du système. C'est elle qui permettra de demander à attacher un fichier à une propriété définie dans le mapping.
- On récupère le mapping correspondant à la propriété
- Si un fichier existe déjà pour cette propriété, on le supprime (via
detachMedia) - On génère le nom du fichier en utilisant la logique définie dans le mapping
- On stocke le fichier sur le disque configuré
- On met à jour la propriété du modèle avec le nouveau nom de fichier
Les méthodes detachMedia() et mediaUrl() suivent la même logique : récupérer le mapping, construire le chemin, puis effectuer l'opération (suppression ou génération d'URL).
Le code
Voici un exemple de trait :
<?php
namespace App\Concerns\Media;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
trait HasMedia
{
/** @var MediaMapping[] */
private static array $mediaMappings = [];
protected static function bootHasMedia(): void
{
self::deleted(function (Model $model) {
foreach (self::$mediaMappings as $property => $mapping) {
$model->detachMedia($property);
}
});
}
protected static function registerMediaForProperty(
string $property,
string $directory,
string|\Closure $filename,
string $disk = 'public'
) {
$mapping = new MediaMapping($property, $directory, $filename, $disk);
self::$mediaMappings[$property] = $mapping;
}
public function attachMedia(UploadedFile $file, string $property): bool
{
$mapping = self::getMediaMappingFor($property);
if (!$this->exists) {
self::created(function (Model $model) use ($file, $property) {
$model->attachMedia($file, $property);
$model->save();
});
return true;
}
$filename = $mapping->getFilename($this, $file);
$directory = $mapping->getDirectory();
$stored = $file->storeAs(
$directory,
$filename,
$mapping->disk
);
if ($stored) {
$this->detachMedia($property);
$this->setAttribute($property, $filename);
}
return boolval($stored);
}
public function detachMedia(string $property): bool
{
$mapping = self::getMediaMappingFor($property);
$filename = $this->getAttribute($property);
if (!$filename) {
return true;
}
return Storage::disk($mapping->disk)->delete(sprintf('%s/%s', $mapping->getDirectory(), $filename));
}
public function mediaUrl(string $property): string
{
$mapping = self::getMediaMappingFor($property);
return Storage::disk($mapping->disk)->url(sprintf('%s/%s', $mapping->getDirectory(), $this->getAttribute($property)));
}
private static function getMediaMappingFor(string $property): MediaMapping{
$mapping = self::$mediaMappings[$property] ?? null;
assert($mapping instanceof MediaMapping, 'There is no media declaration for '. $property);
return $mapping;
}
}
Et le MediaMapping
<?php
namespace App\Concerns\Media;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
final readonly class MediaMapping
{
public function __construct(
private string $property,
private string $directory,
private string|\Closure $filename,
public string $disk = 'public'
){
}
public function getDirectory(): string
{
return $this->directory;
}
public function getFilename(Model $model, UploadedFile $file): string
{
$filename = is_string($this->filename) ? $model->getAttribute($this->filename) : ($this->filename)($model, $file);
return sprintf('%s.%s', $filename, $file->clientExtension());
}
}
Conclusion
On a créé un système simple mais efficace pour gérer les fichiers attachés à nos modèles Eloquent. L'avantage de cette approche par rapport à des packages plus complets :
Les avantages
- Pas de table supplémentaire
- Code simple et compréhensible
- Facilement extensible selon vos besoins
- Contrôle total sur le nommage et le stockage
Les inconvénients
- Pas de gestion des conversions (redimensionnement d'images, etc.)
- Un seul fichier par propriété
- Moins de fonctionnalités que MediaLibrary
Cette solution est idéale pour des cas simples où on veut juste attacher un fichier à un modèle sans la complexité d'un package externe. Pour des besoins plus avancés (collections de médias, conversions automatiques, responsive images), des solutions comme Spatie MediaLibrary restent pertinentes.
L'approche TDD nous a permis de bien réfléchir à l'API avant de l'implémenter, et d'avoir une suite de tests qui garantit le bon fonctionnement du système.