Réalisation d’une galerie photo avec Laravel 12 – Mise en place d’une interface d’administration avec Filament (Partie 5)

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 :

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.

logo de filament

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 --panels

Réponds aux quelques questions de l’assistant et lance la commande suivante pour créer un utilisateur administrateur :

php artisan make:filament-user

Voilà, 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 Photo

Lors 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 title pour Article et Photo, name pour 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 title

A 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:link

Et 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

Publié le 26 octobre 2025

Laisser une réponse

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