Nous voilà dans la dernière partie de la réalisation de notre site avant de pouvoir le publier. Pour rappel, jusque-là nous avons vu comment :
- installer Laravel 12 et les templates Blade
- mettre en place des routes pour notre projet
- créer des tables avec Eloquent et les remplir avec des données tests (seeders)
- communiquer entre la base de données et les vues avec les Controller
Maintenant nous allons mettre en place une interface d’administration complète nous permettant de gérer notre contenu, que ce soit créer des galeries. ajouter des photos ou des articles avec les tags.

Installer Filament
Filament (version 4.1.10 lors de la réalisation de ce tuto) est une collection d’outils fullstack qui permettent d’accélérer le développement d’application web, il s’installe avec composer :
composer require filament/filament:"^4.1"Ensuite nous pouvons lancer l’assistant d’installation :
php artisan filament:install --panelsRéponds aux quelques questions de l’assistant et lance la commande suivante pour créer un utilisateur administrateur :
php artisan make:filament-userVoilà, c’est installé et accessible via l’url de ton server laravel : http://localhost:8000/admin. Il nous faut maintenant mettre en place des Resources pour remplir un peu ce dashboard et surtout pour qu’il sache comment se comporter.
Les Resources
Une Resource est un fichier php qui explique comment utiliser nos Models.
php artisan make:filament-resource Article
php artisan make:filament-resource Tag
php artisan make:filament-resource Gallery
php artisan make:filament-resource PhotoLors de l’exécution de ces commandes Filament tu poseras 3 questions :
- What is the title attribute for this model ? Quel champ de notre Model est à utiliser comme titre ou nom principal. C’est ce qui sera affiché dans les listes déroulantes lors la liaison entre deux Models ou dans les fil d’Ariane. Pour nom projet ce sera
titlepour Article et Photo,namepour Gallery et Tag. - Would you like to generate a read-only view page for the resource ? Par défaut Filament cré trois pages pour chaque Resource : liste, créer et éditer. Cette question demande si il doit ajouter une page de consultation où les champs ne sont pas éditable. Nous répondrons « Non » pour toutes nos Resources (éventuellement oui pour Article).
- Should the configuration be generated from the current database columns ? Nous répondrons oui pour tous les Models. Cela dit à Filament que nous souhaitons qu’il génère automatiquement des ébauches de formulaires et de tableaux.
Personnalisation de l’interface
Pour que Filament utilise toute la largeur de l’écran, nous devons modifier le fichier app/Providers/Filament/AdminPanelProvider.php :
use Filament\Support\Enums\Width; // 1. Importe la classe MaxWidth
public function panel(Panel $panel): Panel
{
return $panel
// ...
->maxContentWidth(Width::Full); // 2. Ajoute cette ligne à la fin de la fonction panel()
}Personnaliser les formulaires et les tables
Dans le fichier app/Filament/Resources/Articles/ArticleResource.php nous pouvons personnaliser l’affichage de la table et du formulaire pour les articles.
columns(3)
->schema([
// --- Section de gauche ---
Section::make('Contenu principal')
->schema([
// Grille interne pour title/slug
Grid::make(2)->schema([
TextInput::make('title')
->label('Titre')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(fn(Set $set, ?string $state) => $set('slug', Str::slug($state)))
->autofocus(),
TextInput::make('slug')
->required()
->maxLength(255),
]),
RichEditor::make('content')
->label("Contenu")
->required()
->columnSpanFull() // Prend toute la largeur DE CETTE SECTION
->extraInputAttributes(['style' => 'min-height: 400px']),
])
->columnSpan(2), // PREND 2 COLONNES SUR LES 3 PRINCIPALES
// --- Section de droite ---
Section::make('Métadonnées')
->schema([
FileUpload::make('featured_image')
->label('Image à la une')
->image()
->directory('articles')
->visibility('public'),
Select::make('status')
->options([
'draft' => 'Brouillon',
'published' => 'Publié',
])
->required()
->default('draft'),
Select::make('tags')
->multiple()
->relationship(name: 'tags', titleAttribute: 'name')
->preload()
->searchable(),
])
->columnSpan(1), // PREND 1 COLONNE SUR LES 3 PRINCIPALES
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
ImageColumn::make('featured_image')
->label('Image')
->square(),
TextColumn::make('title')
->label('Titre')
->searchable()
->sortable(),
BadgeColumn::make('status')
->label('Statut')
->colors([
'warning' => 'draft',
'success' => 'published',
])
->formatStateUsing(
fn(string $state): string =>
$state === 'draft' ? 'Brouillon' : 'Publié'
),
// 1. COLONNE POUR LES TAGS
TextColumn::make('tags.name') // Accède au champ 'name' de la relation 'tags'
->label('Tags')
->badge() // Affiche les tags comme des badges
->searchable(), // Permet de rechercher par nom de tag
// 2. COLONNE POUR LA DATE DE MODIFICATION
TextColumn::make('updated_at')
->label('Dernière modif.')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: false), // Visible par défaut
// L'ancienne colonne created_at (gardée mais cachée)
TextColumn::make('created_at')
->label('Créé le')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('status')
->options([
'draft' => 'Brouillon',
'published' => 'Publié',
])
->label('Statut'),
// 3. FILTRE POUR LES TAGS
SelectFilter::make('tags')
->relationship('tags', 'name') // Cible la relation
->multiple() // Permet de sélectionner plusieurs tags
->preload() // Charge les tags à l'avance
->label('Tags'),
])
// --- Actions de ligne
->recordActions([
ActionGroup::make([
EditAction::make(),
DeleteAction::make(),
]),
])
// --- Actions groupées
->groupedBulkActions([
BulkActionGroup::make([
BulkAction::make('delete')
->label('Supprimer la sélection')
->requiresConfirmation()
->action(fn(Collection $records) => $records->each->delete()),
]),
])
->defaultSort('updated_at', 'desc'); // Votre tri par défaut
}
public static function infolist(Schema $schema): Schema
{
return ArticleInfolist::configure($schema);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListArticles::route('/'),
'create' => CreateArticle::route('/create'),
'view' => ViewArticle::route('/{record}'),
'edit' => EditArticle::route('/{record}/edit'),
];
}
}
Avant de personnaliser les formulaires et tables des galeries et des photos, nous devons créer une relation entre les deux (pour que nous puissions afin le contenu d’une galerie sous son formulaire) :
php artisan make:filament-relation-manager GalleryResource photos titleA la question : Filament can link this to an existing resource, which will open the resource’s pages instead of modals when links are clicked. It will also inherit the resource’s configuration. Réponds simplement non. Pourquoi ?
- Si tu répondez « Oui » : Quand tu cliqueras sur « Modifier » une photo depuis la page d’une galerie, tu seras redirigé vers la page principale de modification de cette photo (ex:
/admin/photos/123/edit). Tu devras ensuite cliquer sur « Retour » pour revenir à la galerie. - Si tu réponds « Non » (Recommandé) : Quand tu cliqueras sur « Modifier », une pop-up s’ouvrira par-dessus la page de la galerie. C’est beaucoup plus rapide et fluide pour gérer les photos d’une galerie.
A la question Should there be a read-only « view » modal on the relation manager? Réponds également non, nous n’avons pas besoin d’une page de vue en lecture seule.
Et finalement Should the configuration be generated from the current database columns? oui cela créera automatiquement une ébauche de formulaire. C’est un gain de temps énorme.
app/Filament/Resources/Articles/GallerieResource.php :
<?php
namespace App\Filament\Resources\Galleries;
use App\Models\Gallery;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use App\Filament\Resources\Galleries\Pages;
use App\Filament\Resources\Galleries\RelationManagers;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Illuminate\Support\Str;
use Filament\Tables\Columns\TextColumn;
use Illuminate\Database\Eloquent\Collection;
use Filament\Actions\ActionGroup;
use Filament\Actions\EditAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
class GalleryResource extends Resource
{
protected static ?string $navigationLabel = 'Galeries'; // Nom dans le menu
protected static ?string $modelLabel = 'Galerie'; // Nom au singulier
protected static ?string $pluralModelLabel = 'Galeries'; // Nom au pluriel
protected static ?string $model = Gallery::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
protected static ?string $recordTitleAttribute = 'name';
public static function form(Schema $schema): Schema
{
return $schema
->schema([
TextInput::make('name')
->label('Nom de la galerie')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(fn(\Filament\Schemas\Components\Utilities\Set $set, ?string $state) => $set('slug', Str::slug($state))),
TextInput::make('slug')
->required()
->maxLength(255),
Textarea::make('description')
->label('Description')
->rows(5)
->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Nom')
->searchable()
->sortable(),
// Ajout d'un compteur de photos (très utile)
TextColumn::make('photos_count')
->counts('photos') // Compte la relation 'photos'
->label('Nb Photos')
->sortable(),
TextColumn::make('updated_at')
->label('Dernière modif.')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: false),
])
->filters([
// (Pas de filtres évidents pour le moment)
])
// Actions de ligne (Edit, Delete)
->recordActions([
ActionGroup::make([
EditAction::make(),
DeleteAction::make(),
]),
])
// Actions groupées (Suppression)
->groupedBulkActions([
BulkActionGroup::make([
BulkAction::make('delete')
->label('Supprimer la sélection')
->requiresConfirmation()
->action(fn(Collection $records) => $records->each->delete()),
]),
])
// Tri par défaut
->defaultSort('updated_at', 'desc');
}
// --- Exigence : RelationManager pour les photos ---
public static function getRelations(): array
{
return [
// Assure que le RelationManager s'affiche sur la page d'édition
RelationManagers\PhotosRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListGalleries::route('/'),
'create' => Pages\CreateGallery::route('/create'),
'edit' => Pages\EditGallery::route('/{record}/edit'),
];
}
}
app/Filament/Resources/Galleries/RelationManagers/PhotosRelationManager.php :
<?php
namespace App\Filament\Resources\Galleries\RelationManagers;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\FileUpload;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ImageColumn;
use Filament\Actions\CreateAction;
use Filament\Actions\EditAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
class PhotosRelationManager extends RelationManager
{
protected static string $relationship = 'photos';
public function form(Schema $schema): Schema
{
return $schema
->components([
Grid::make(2)->schema([
TextInput::make('title')
->required()
->live(onBlur: true)
->afterStateUpdated(fn(Set $set, ?string $state) => $set('slug', Str::slug($state))),
TextInput::make('slug')
->required(),
]),
FileUpload::make('filename')
->label('Image')
->required()
->image()
->directory('photos')
->visibility('public')
->columnSpanFull(),
Textarea::make('description')
->columnSpanFull(),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('title')
->columns([
ImageColumn::make('filename')
->label('Image')
->square(),
TextColumn::make('title')->searchable(),
TextColumn::make('slug')
->searchable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->label('Dernière modif.')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: false),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->headerActions([
CreateAction::make(),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
])
->toolbarActions([ // "toolbarActions" est le bon nom pour les actions groupées
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
])
->defaultSort('updated_at', 'desc');
}
}app/Filament/Resources/Articles/PhotoResource.php :
<?php
namespace App\Filament\Resources\Photos;
use App\Filament\Resources\Photos\Pages;
use App\Models\Photo;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Filament\Support\Icons\Heroicon;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\FileUpload;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ImageColumn;
use Filament\Actions\EditAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use BackedEnum;
class PhotoResource extends Resource
{
protected static ?string $model = Photo::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
protected static ?string $navigationLabel = 'Photos'; // Nom dans le menu
protected static ?string $modelLabel = 'Photo'; // Nom au singulier
protected static ?string $pluralModelLabel = 'Photos'; // Nom au pluriel
protected static ?string $recordTitleAttribute = 'title';
public static function form(Schema $schema): Schema
{
return $schema
->components([
Section::make('Informations')
->schema([
Grid::make(2)->schema([
TextInput::make('title')
->label('Titre')
->required()
->live(onBlur: true)
->afterStateUpdated(fn(Set $set, ?string $state) => $set('slug', Str::slug($state))),
TextInput::make('slug')
->required(),
]),
Textarea::make('description')
->label('Description')
->rows(5)
->columnSpanFull(),
]),
Section::make('Fichier & Galerie')
->schema([
Select::make('gallery_id')
->label('Galerie')
->relationship('gallery', 'name')
->required()
->searchable()
->preload(),
FileUpload::make('filename')
->label('Image')
->required()
->image()
->directory('photos')
->visibility('public'),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
ImageColumn::make('filename')
->label('Image')
->square(),
TextColumn::make('title')
->label('Titre')
->searchable()
->sortable(),
// Affiche à quelle galerie la photo appartient
TextColumn::make('gallery.name')
->label('Galerie')
->searchable()
->sortable()
->badge(), // Affiche comme un badge
TextColumn::make('updated_at')
->label('Dernière modif.')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: false),
TextColumn::make('created_at')
->label('Créé le')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
// (On pourrait ajouter un filtre par Galerie ici si besoin)
])
// Actions de ligne
->recordActions([
EditAction::make(),
DeleteAction::make(),
])
// Actions groupées
->groupedBulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->label('Supprimer la sélection')
->requiresConfirmation()
]),
])
// Tri par défaut
->defaultSort('updated_at', 'desc');
}
public static function getRelations(): array
{
return [
// Normalement vide, car on ne gère rien DEPUIS une photo
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListPhotos::route('/'),
'create' => Pages\CreatePhoto::route('/create'),
'edit' => Pages\EditPhoto::route('/{record}/edit'),
];
}
}app/Filament/Resources/Articles/TagResource.php :
<?php
namespace App\Filament\Resources\Tags;
use App\Filament\Resources\Tags\Pages;
use App\Models\Tag;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ColorPicker;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ColorColumn;
use Filament\Actions\EditAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use BackedEnum;
use Filament\Support\Icons\Heroicon;
class TagResource extends Resource
{
protected static ?string $model = Tag::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
protected static ?string $navigationLabel = 'Tags';
protected static ?string $modelLabel = 'Tag';
protected static ?string $pluralModelLabel = 'Tags';
protected static ?string $recordTitleAttribute = 'name';
public static function form(Schema $schema): Schema
{
return $schema
->components([
Section::make('Informations')
->schema([
Grid::make(2)->schema([
TextInput::make('name')
->label('Nom')
->required()
->live(onBlur: true)
->afterStateUpdated(fn(Set $set, ?string $state) => $set('slug', Str::slug($state))),
TextInput::make('slug')
->required(),
]),
ColorPicker::make('color')
->label('Couleur'),
]),
]);
}
// --- Exigence : Tableau ---
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Nom')
->searchable()
->sortable(),
ColorColumn::make('color')
->label('Couleur'),
// Compte des articles associés
TextColumn::make('articles_count')
->counts('articles')
->label('Nb Articles')
->sortable(),
TextColumn::make('updated_at')
->label('Dernière modif.')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: false),
TextColumn::make('created_at')
->label('Créé le')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
// Actions de ligne
->recordActions([
ActionGroup::make([
EditAction::make(),
DeleteAction::make(),
]),
])
// Actions groupées
->groupedBulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->label('Supprimer la sélection')
->requiresConfirmation()
]),
])
// Tri par défaut
->defaultSort('updated_at', 'desc');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListTags::route('/'),
'create' => Pages\CreateTag::route('/create'),
'edit' => Pages\EditTag::route('/{record}/edit'),
];
}
}N’oublie pas de créer un lien symbolique dans public/storage qui pointe vers storage/app/public, sinon les images uploadées ne seront pas accessible :
php artisan storage:linkEt voilà c’est terminé nous avons une interface d’administration fonctionnelle. Elle peut encore être améliorée avec par exemple :
- Meilleure gestion des photos avec Spatie Laravel Media Library
- Ajouter des widgets sur la page du tableau de bord
- Possibilité de faire des cliquer/glisser pour changer l’ordre des photos
- Meilleure interface UI/UX