Brouillon, publié, ordre d'affichage
Temps 1 · séance 4 · ~45 min
Les slides du chapitre (PDF)PDF
- Masquer un projet non terminé avec la colonne
published_atet un scopepublished(). - Fermer l'adresse d'un brouillon avec une 404.
- Publier et dépublier un projet depuis le back-office, par un
<Form>sur une routePATCH. - En bonus, changer l'ordre d'affichage avec deux boutons « Monter » et « Descendre ».
1. Un interrupteur : published_at
Un projet à moitié écrit ne doit pas apparaître sur votre site. Il doit pourtant exister dans la base, pour que vous le terminiez depuis le back-office.
La colonne est prête depuis le chapitre 1.06. published_at est une date, et elle accepte null :
$table->timestamp('published_at')->nullable();
Le portfolio s'en sert comme d'un interrupteur. Une date veut dire « publié », null veut dire « brouillon ». La fabrique a déjà son état draft(), qui vide la colonne, et vos seeders publient tout avec now().
Pour avoir un brouillon à regarder, faites passer un de vos projets en brouillon dans database/seeders/ProjectSeeder.php. L'exemple du cours choisit le « Carnet de recettes » :
$recipes = Project::create([
'title' => 'Carnet de recettes',
// slug, résumé, contenu, image et liens : inchangés
'published_at' => null,
'position' => 3,
]);
Relancez php artisan migrate:fresh --seed. Le site public montre encore les trois projets : rien ne lit la colonne pour l'instant.
2. Le scope published()
La condition « publié » s'écrit whereNotNull('published_at'). Elle servira dans la liste publique, puis dans d'autres pages, comme l'accueil. Plutôt que de la recopier, donnez-lui un nom dans le modèle app/Models/Project.php :
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
/**
* @param Builder<Project> $query
*/
#[Scope]
protected function published(Builder $query): void
{
$query->whereNotNull('published_at');
}
Un scope est une condition de requête nommée, rangée dans le modèle. L'attribut #[Scope] le déclare. Il appartient à la même famille que l'attribut #[Fillable] de votre modèle. La méthode reçoit la requête en cours et lui ajoute sa condition, sans rien renvoyer.
Vous l'appelez comme une méthode statique du modèle, au début d'une requête :
php artisan tinker --execute="echo App\Models\Project::published()->pluck('title')->implode(', ');"
Météo Wavre, Quiz des capitales
Le « Carnet de recettes » n'y est plus. Project::all() les donnerait toujours tous les trois.
Note
Vous rencontrerez aussi l'ancienne écriture, public function scopePublished(Builder $query), avec le préfixe scope. Elle fonctionne toujours dans Laravel 13. L'attribut #[Scope] garde le vrai nom de la méthode.
3. La partie publique
Dans le ProjectController public, la liste commence maintenant par le scope :
$projects = Project::published()
->with('tags')
->orderBy('position')
// get() et map() : inchangés
Ouvrez portfolio.test/projets : votre brouillon a disparu de la liste.
Ouvrez ensuite son adresse directement, /projets/ suivi de son slug. La fiche s'affiche. La liaison de route ne connaît pas le scope : {project:slug} cherche le projet par son slug dans toute la table, brouillons compris. Un visiteur qui a gardé l'adresse, ou qui la devine, lit votre brouillon.
Fermez la porte au début de show() :
public function show(Project $project): Response
{
abort_if($project->published_at === null, 404);
// load('tags') et Inertia::render : inchangés
}
abort_if(condition, 404) arrête la requête avec le code donné quand la condition est vraie. Le brouillon répond 404, avec votre page ErrorPage du chapitre 1.08.
4. La liste d'administration
Le back-office, lui, doit tout montrer. Sa méthode index() ne change pas : depuis le chapitre 1.11, elle lit tous les projets et envoie déjà published_at à la page.
Ce qui manque, c'est l'état de chaque ligne. Le kit contient le composant Badge de shadcn-vue, une petite étiquette arrondie. Dans resources/js/pages/admin/projects/Index.vue, importez-le :
import { Badge } from '@/components/ui/badge';
Ajoutez une colonne « État » au tableau, avec son <TableHead> dans l'en-tête et cette cellule dans chaque ligne :
<TableCell>
<Badge v-if="project.published_at">Publié</Badge>
<Badge v-else variant="secondary">Brouillon</Badge>
</TableCell>
La variante par défaut est foncée, la variante secondary est grise. v-if et v-else choisissent l'une ou l'autre selon la date. Si le tableau devient trop large, retirez la colonne de l'adresse du chapitre 1.11.
5. Publier et dépublier
Le bouton « Publier » modifie une seule colonne d'un projet. Il lui faut une route, une méthode et un formulaire.
La route
Dans routes/web.php, ajoutez une route dans le groupe admin, après la ressource des projets :
Route::patch('projets/{project}/publication', [Admin\ProjectController::class, 'togglePublished'])
->name('projects.toggle-published');
PATCH est la méthode HTTP d'une modification partielle : une colonne, pas tout le projet. Le groupe ajoute le préfixe admin à l'adresse et admin. au nom. Vérifiez :
php artisan route:list --path=admin
…
PATCH admin/projets/{project}/publication admin.projects.toggle-published › Admin\ProjectController@toggl…
La méthode
Dans Admin\ProjectController :
public function togglePublished(Project $project): RedirectResponse
{
$project->update([
'published_at' => $project->published_at ? null : now(),
]);
Inertia::flash('toast', [
'type' => 'success',
'message' => $project->published_at ? 'Projet publié.' : 'Projet repassé en brouillon.',
]);
return to_route('admin.projects.index');
}
L'opérateur ternaire, condition ? valeur si vrai : valeur si faux, inverse l'interrupteur. Un projet publié reçoit null, un brouillon reçoit la date du moment. Le message du toast relit la colonne après la mise à jour, et dit ce qui vient de se passer.
Le formulaire
Dans Index.vue, ajoutez ce formulaire dans la cellule des actions, avant le bouton « Modifier » :
<Form
v-bind="ProjectController.togglePublished.form(project)"
class="inline"
>
<Button type="submit" variant="outline" size="sm">
{{ project.published_at ? 'Dépublier' : 'Publier' }}
</Button>
</Form>
ProjectController est déjà importé depuis le chapitre 1.13, pour la suppression. Pendant que composer run dev tourne, Wayfinder génère togglePublished dès que la route et la méthode sont enregistrées : la fonction porte le nom de la méthode du contrôleur. Comme pour destroy.form(project), l'objet project doit contenir id, et c'est le cas.
Ce <Form> n'a aucun champ. Il ne sert qu'à envoyer une requête à la bonne adresse quand vous cliquez sur son bouton submit. Voici la requête relevée dans le navigateur au clic sur « Publier » pour le « Carnet de recettes » :
POST http://…/admin/projets/3/publication?_method=PATCH body {}
Comme pour la modification au chapitre 1.13, Wayfinder envoie un POST avec ?_method=PATCH dans l'adresse, et Laravel appelle la route PATCH. Le corps est un objet JSON vide. La page revient avec le badge « Publié » et le toast « Projet publié. ».

6. Bonus : l'ordre d'affichage
Ce qui suit n'est pas obligatoire. Faites-le si la partie obligatoire est finie et vérifiée.
Depuis le chapitre 1.06, la colonne position décide de l'ordre : la liste publique et la liste d'administration trient par orderBy('position'), et store() donne à chaque nouveau projet la position qui suit la plus grande. Pour changer l'ordre, il suffit d'échanger la position d'un projet avec celle de son voisin.
Deux routes et deux méthodes
Dans le groupe admin de routes/web.php :
Route::patch('projets/{project}/monter', [Admin\ProjectController::class, 'moveUp'])
->name('projects.move-up');
Route::patch('projets/{project}/descendre', [Admin\ProjectController::class, 'moveDown'])
->name('projects.move-down');
Dans Admin\ProjectController :
public function moveUp(Project $project): RedirectResponse
{
$previous = Project::where('position', '<', $project->position)
->orderByDesc('position')
->first();
if ($previous) {
$this->swapPositions($project, $previous);
}
return to_route('admin.projects.index');
}
private function swapPositions(Project $first, Project $second): void
{
[$first->position, $second->position] = [$second->position, $first->position];
$first->save();
$second->save();
}
moveUp() cherche le voisin du dessus : parmi les projets de position plus petite, celui qui a la plus grande. Le premier projet n'en a pas, et rien ne se passe. swapPositions() échange les deux valeurs en une ligne : à gauche du =, les deux propriétés reçoivent, dans l'ordre, les deux valeurs du tableau de droite.
Écrivez moveDown() seul. C'est la même méthode avec '>' et orderBy('position'), pour trouver le voisin du dessous.
Deux boutons par ligne
Dans Index.vue, importez les deux flèches de lucide, la bibliothèque d'icônes du kit :
import { ArrowDown, ArrowUp } from '@lucide/vue';
Le v-for du tableau doit aussi connaître le rang de chaque ligne. Vue le donne en second élément, entre parenthèses :
<TableRow v-for="(project, position) in projects" :key="project.id">
Ajoutez une première colonne « Ordre » avec ces deux formulaires :
<TableCell class="flex gap-1">
<Form v-bind="ProjectController.moveUp.form(project)">
<Button
type="submit"
variant="ghost"
size="icon-sm"
aria-label="Monter"
:disabled="position === 0"
>
<ArrowUp />
</Button>
</Form>
<Form v-bind="ProjectController.moveDown.form(project)">
<Button
type="submit"
variant="ghost"
size="icon-sm"
aria-label="Descendre"
:disabled="position === projects.length - 1"
>
<ArrowDown />
</Button>
</Form>
</TableCell>
position vaut ici 0 pour la première ligne. Le bouton « Monter » de la première ligne et le bouton « Descendre » de la dernière sont désactivés. Un bouton qui ne montre qu'une icône porte un aria-label, lu par les lecteurs d'écran.
Cliquez sur « Descendre » pour votre premier projet. La requête part comme celle de la publication :
POST http://…/admin/projets/1/descendre?_method=PATCH body {}
La page revient avec les deux premières lignes échangées. La liste publique suit le même ordre.
7. Pratique : brouillons et publication (30 min)
Faites seul ce qui est décrit plus haut, dans votre portfolio.
Obligatoire :
- Un de vos projets en brouillon dans le seeder, puis
php artisan migrate:fresh --seed. - Le scope
published()dansProject, essayé dans Tinker. - La liste publique qui n'affiche que les projets publiés.
abort_ifdansshow(), pour que l'adresse d'un brouillon réponde 404.- La colonne « État » avec un
Badgedans la liste d'administration. - La route
PATCHadmin.projects.toggle-published, la méthodetogglePublished()et son<Form>.
Vérifiez dans le navigateur.
portfolio.test/projetsne montre pas votre brouillon.- L'adresse de votre brouillon répond par votre page 404.
/admin/projetsmontre tous vos projets, avec un badge « Publié » ou « Brouillon ».- « Publier » fait apparaître le projet sur le site, avec le toast « Projet publié. », et « Dépublier » le retire.
Bonus :
- Les routes
monteretdescendre,moveUp(),moveDown()etswapPositions(). - La colonne « Ordre » avec ses deux boutons, désactivés aux extrémités.
- « Descendre » sur la première ligne l'échange avec la deuxième, dans le back-office et sur le site public.
Ce que votre portfolio contient maintenant
Des projets publiés et des brouillons. Le site public ne montre que les premiers, et l'adresse d'un brouillon répond 404. Le back-office montre l'état de chaque projet et le publie ou le dépublie d'un clic. En bonus, deux flèches par ligne règlent l'ordre d'affichage.
À retenir
published_atsert d'interrupteur : une date pour publié,nullpour brouillon.- Un scope
#[Scope]donne un nom à une condition de requête, etProject::published()l'applique. - La liaison de route ignore le scope, donc
show()refuse un brouillon avecabort_if(..., 404). - Un bouton qui modifie un projet est un
<Form>sans champ, lié par Wayfinder à une routePATCH, avec un boutontype="submit". - Échanger la
positionde deux voisins suffit à changer l'ordre, sans glisser-déposer.