Construire un système simple de commentaires Laravel

Fait partie de la série Améliorer votre blog VitePress avec une API Laravel

L'authentification GitHub est en place. Nous pouvons maintenant construire la fonctionnalité qu'elle rend possible : les commentaires.

Avant d'écrire du code, définissons la première version du système de commentaires. Nous voulons prendre en charge les interactions essentielles sans ajouter plus de complexité que nécessaire :

  • Les utilisateurs doivent pouvoir laisser des commentaires sur les articles.
  • Les utilisateurs doivent pouvoir modifier ou supprimer leurs commentaires.
  • Les réponses aux commentaires ne sont pas prévues.
  • La prise en charge du Markdown n'est pas prévue.

Pour l'instant, nous voulons un système de commentaires volontairement simple.

Note

Je souhaite garder le code simple pour deux raisons. D'abord, le processus et les décisions qui le sous-tendent sont plus importants que la quantité de code. Ensuite, des fonctionnalités plus avancées pourront être explorées dans une future série.

Construire le système de commentaires

Maintenant que les exigences sont claires, mettons-les en relation avec les concepts de notre application.

Un commentaire est un message laissé par un utilisateur sur un article. Dans notre système, le modèle possédera les propriétés suivantes :

  • id : L'identifiant unique du commentaire.
  • post_id : L'identifiant de l'article auquel le commentaire appartient.
  • user_id : L'identifiant de l'utilisateur qui a laissé le commentaire.
  • content : Le contenu du commentaire.
  • created_at : La date et l'heure de création du commentaire.
  • updated_at : La date et l'heure de sa dernière modification.

user_id référence la table users, et le modèle User possédera une relation comments. Qu'en est-il de post_id ?

Pour post_id, nous avons besoin d'un modèle Post avec les propriétés suivantes :

  • id : L'identifiant unique de l'article.
  • meta_id : L'identifiant unique provenant du front-end.
  • title : Le titre de l'article.
  • created_at : La date et l'heure de création de l'article.
  • updated_at : La date et l'heure de sa dernière modification.

Le modèle Post possédera une relation comments, ce qui nous permettra d'associer plusieurs commentaires à chaque article. Nous parlerons de meta_id dans l'article suivant, vous pouvez donc ignorer son rôle pour le moment.

Les relations ressembleront à ceci :

Une fois les modèles et les relations définis, nous pouvons concevoir l'API qui permettra de récupérer, créer, modifier et supprimer les commentaires.

Commençons par lister les endpoints nécessaires :

  • Lister tous les commentaires d'un article, quelle que soit leur origine
  • Créer un commentaire avec un utilisateur authentifié
  • Modifier un commentaire avec l'utilisateur qui l'a créé
  • Supprimer un commentaire avec l'utilisateur qui l'a créé

Associons ensuite les endpoints aux routes :

  • GET /posts/{post}/comments : Lister tous les commentaires d'un article
  • POST /posts/{post}/comments : Créer un commentaire
  • PUT /comments/{comment} : Modifier un commentaire
  • DELETE /comments/{comment} : Supprimer un commentaire

L'association est simple. Pourquoi utiliser /comments/{comment} plutôt que /posts/{post}/comments/{comment} pour les deux dernières routes ? L'identifiant d'un commentaire est unique, il permet donc de l'identifier sans répéter celui de l'article. Dans Laravel, on appelle cela le shallow nesting, ou imbrication superficielle.

Résumé avant d'écrire le code

Avant de commencer à coder, résumons les étapes d'implémentation :

  1. Créer un modèle Post avec sa migration.
bash
php artisan make:model Post -m -f
  1. Créer un modèle Comment avec sa migration.
bash
php artisan make:model Comment -m -f
  1. Créer un CommentController dans app/Http/Api.
bash
php artisan make:controller Api/CommentController
  1. Créer un PostCommentController dans app/Http/Api.
bash
php artisan make:controller Api/PostCommentController
  1. Ajouter les routes à routes/api.php.

Nous ajouterons les tests au fur et à mesure.

Note

Je n'expliquerai pas chaque test en détail. Je me concentrerai sur les plus importants.

Prêt ? C'est parti !

Consulter et créer des commentaires

Créez le modèle Post avec Artisan :

bash
php artisan make:model Post -m -f

Ouvrons maintenant le fichier de migration et ajoutons le code suivant :

php
return new class extends Migration
{
    Schema::create('posts', function (Blueprint $table) {
        $table->id();

        $table->string('title'); 
        $table->unsignedInteger('meta_id')->unique(); 

        $table->timestamps();
    });
};

Mettez à jour la factory pour qu'elle corresponde à la migration :

php
class PostFactory extends Factory
{
    public function definition(): array
    {
        return [
            'title' => fake()->sentence(), 
            'meta_id' => fake()->unique()->numberBetween(1, 1000), 
        ];
    }
}

Ajoutons un test pour vérifier que le modèle possède la forme attendue.

Commençons par créer le fichier de test :

bash
mkdir -p tests/Unit/Models && touch tests/Unit/Models/PostTest.php

Puis ajoutons le code suivant :

php
<?php

declare(strict_types=1);

use App\Models\Post;

test('to array', function () {
    $post = Post::factory()->create()->fresh();

    expect(array_keys($post->toArray()))
        ->toEqual([
            'id',
            'title',
            'meta_id',
            'created_at',
            'updated_at',
        ]);
});

Ce test minimal vérifie les propriétés persistées du modèle. Il peut détecter la suppression ou l'ajout accidentel d'une colonne dans une migration. Nous ajouterons un test similaire pour chaque modèle créé.

Pour vérifier que le test fonctionne, le dossier tests/Unit doit avoir accès à une base de données fonctionnelle. Ajoutons Unit à la méthode in du fichier tests/Pest.php :

php
pest()->extend(Tests\TestCase::class)
    ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
    ->in('Http', 'Unit');

Créons le modèle Comment de la même manière :

bash
php artisan make:model Comment -m -f

Mettez à jour sa migration :

php
use App\Models\Post; 
use App\Models\User; 

return new class extends Migration
{
    Schema::create('comments', function (Blueprint $table) {
        $table->id();

        $table->foreignIdFor(Post::class)->constrained()->cascadeOnDelete(); 
        $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete(); 
        $table->text('content'); 

        $table->timestamps();
    });
};

Mettez à jour la factory pour qu'elle corresponde à la migration :

php
use App\Models\Post; 
use App\Models\User; 

// ...
class CommentFactory extends Factory
{
    public function definition(): array
    {
        return [
            'post_id' => Post::factory(), 
            'user_id' => User::factory(), 
            'content' => fake()->paragraph(), 
        ];
    }
}

Ajoutez le test correspondant :

php
<?php

declare(strict_types=1);

use App\Models\Comment;

test('to array', function () {
    $comment = Comment::factory()->create()->fresh();

    expect(array_keys($comment->toArray()))
        ->toEqual([
            'id',
            'post_id',
            'user_id',
            'content',
            'created_at',
            'updated_at',
        ]);
});

Ajoutons maintenant les relations. Un article possède plusieurs commentaires, un commentaire appartient à un article, un commentaire appartient à un utilisateur et un utilisateur possède plusieurs commentaires.

Note

Nous utilisons belongs to plutôt que has one, car un commentaire est un enfant d'un article et ne peut pas exister sans lui. Une relation has one décrit le modèle parent qui possède un enfant unique, comme un utilisateur qui possède un profil alors que le profil appartient à l'utilisateur.

Voici ces relations :

php
class Post extends Model
{
    /**
     * Get all of the comments for the Post
     *
     * @return HasMany<Comment, covariant Post>
     */
    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }
}
php
class Comment extends Model
{
    /**
     * Get the post that owns the comment.
     *
     * @return BelongsTo<Post, covariant Comment>
     */
    public function post(): BelongsTo
    {
        return $this->belongsTo(Post::class);
    }

    /**
     * Get the user that owns the comment.
     *
     * @return BelongsTo<User, covariant Comment>
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}
php
class User extends Authenticatable
{
    /**
     * Get comments that belong to the user.
     *
     * @return HasMany<Comment, covariant User>
     */
    public function comments(): HasMany
    {
        return $this->hasMany(Comment::class);
    }
}

Ces relations simplifient les requêtes Eloquent. Pour récupérer tous les commentaires d'un article, nous pouvons écrire :

php
Post::find(1)->comments;

Elles facilitent également l'utilisation de méthodes de factory comme has et for pour créer des modèles liés.

Ajoutons des tests de relations pour vérifier que les modèles sont reliés comme prévu.

Note

Lorsque je travaille sur des modèles, j'écris souvent les tests après la première implémentation, car la conception peut évoluer rapidement. Écrire le modèle m'aide à clarifier ce que les tests doivent vérifier.

php
use App\Models\Comment;

test('relations', function () {
    $post = Post::factory()
        ->hasComments(1)
        ->create();

    expect($post->comments)->each->toBeInstanceOf(Comment::class);
});
php
use App\Models\Post;
use App\Models\User;

test('relations', function () {
    $comment = Comment::factory()
        ->forPost()
        ->create();

    expect($comment->post)->toBeInstanceOf(Post::class)
        ->and($comment->user)->toBeInstanceOf(User::class);
});
php
<?php

declare(strict_types=1);

use App\Models\Comment;
use App\Models\User;

test('to array', function () {
    $user = User::factory()->create()->fresh();

    expect(array_keys($user->toArray()))
        ->toEqual([
            'id',
            'name',
            'username',
            'email',
            'email_verified_at',
            'github_id',
            'avatar',
            'created_at',
            'updated_at',
        ]);
});

test('relations', function () {
    $user = User::factory()
        ->hasComments(1)
        ->create();

    expect($user->comments)->each->toBeInstanceOf(Comment::class);
});

Nous n'avions pas encore ajouté de tests pour User, c'est donc une bonne occasion d'ajouter également son test de forme.

Le fonctionnement reste cohérent et Laravel rend chaque étape simple.

Créer les contrôleurs

Maintenant que les modèles sont prêts, nous pouvons créer les contrôleurs.

PostCommentController possédera deux méthodes :

  • index : Liste tous les commentaires d'un article avec GET /posts/{post}/comments.
  • store : Crée un commentaire avec POST /posts/{post}/comments.

Comme dans l'article précédent, commençons par écrire un test pour la méthode index :

php
<?php

declare(strict_types=1);

use App\Models\Comment;
use App\Models\Post;
use Illuminate\Testing\Fluent\AssertableJson;

it('returns comments', function () {
    $post = Post::factory()->create();
    $comments = Comment::factory()
        ->count(2)
        ->for($post)
        ->create();

    $this->getJson(route('posts.comments', $post))
        ->assertJson(
            fn (AssertableJson $json) => $json
                ->has(
                    'data',
                    2,
                    fn (AssertableJson $json) => $json
                        ->where('id', $comments->first()->id)
                        ->where('content', $comments->first()->content)
                        ->has(
                            'author',
                            fn (AssertableJson $json) => $json
                                ->where('name', $comments->first()->user->name)
                                ->where('username', $comments->first()->user->username)
                                ->where('avatar', $comments->first()->user->avatar)
                        )
                )
        );
});

Pour les tests HTTP, j'utilise le pattern AAA :

  • Arrange : préparer les données
  • Act : appeler la méthode
  • Assert : vérifier le résultat

Dans ce test, nous créons un article et deux commentaires associés. C'est l'étape Arrange. Nous appelons ensuite GET /posts/{post}/comments, l'étape Act. Enfin, nous vérifions que la réponse contient deux commentaires avec la structure attendue, l'étape Assert.

Le test en échec nous demande d'ajouter une route dans routes/api.php :

php
<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\PostCommentController;

Route::get('/posts/{post}/comments', [PostCommentController::class, 'index'])
    ->name('posts.comments');

Nous devons ensuite demander à Laravel de lire ce fichier en mettant à jour bootstrap/app.php :

php
<?php

declare(strict_types=1);

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php', 
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )

Enfin, créons le contrôleur avec une méthode index :

bash
php artisan make:controller Api/PostCommentController
php
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class PostCommentController extends Controller
{
    public function index(Request $request)
    {
        //
    }
}

La route existe maintenant, mais le test indique que la réponse n'est pas un JSON valide, car le contrôleur ne retourne rien. 😄

Utilisons cet échec comme prochaine étape d'implémentation :

php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Resources\CommentResource;
use App\Models\Comment;
use Illuminate\Http\Resources\Json\ResourceCollection;

final class PostCommentController extends Controller
{
    public function index(): ResourceCollection
    {
        $comments = Comment::query()
          ->with('user')
          ->get();

        return CommentResource::collection($comments);
    }
}

La méthode index récupère les commentaires, charge la relation utilisateur à l'avance et retourne une collection de ressources CommentResource.

Note

Récupérer tous les enregistrements n'est généralement pas une bonne pratique ; la pagination est plus sûre pour une grande collection. Dans cet exemple, nous nous attendons à un petit nombre de commentaires, le retour de la collection est donc acceptable. Nous pourrons ajouter la pagination si cette hypothèse change.

Créons maintenant la classe CommentResource :

bash
php artisan make:resource CommentResource
php
<?php

declare(strict_types=1);

namespace App\Http\Resources;

use App\Models\Comment;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/**
 * @mixin Comment
 */
final class CommentResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'content' => $this->content,
            'author' => UserResource::make($this->whenLoaded('user')),
        ];
    }
}

Et une seconde ressource pour l'auteur :

bash
php artisan make:resource UserResource
php
<?php

declare(strict_types=1);

namespace App\Http\Resources;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/**
 * @mixin User
 */
final class UserResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'name' => $this->name,
            'username' => $this->username,
            'avatar' => $this->avatar,
        ];
    }
}

Que sont ces ressources ?

Une ressource est une couche de transformation entre nos modèles Eloquent et les réponses JSON envoyées au client. Elle définit comment un modèle est transformé en JSON.

Ici, nous retournons l'id, le content et l'author d'un commentaire. La ressource imbriquée de l'auteur n'expose que le name, le username et l'avatar de l'utilisateur. Cela permet de modifier la forme de la réponse sans toucher au contrôleur et évite d'exposer accidentellement des champs comme l'e-mail de l'utilisateur.

Le test devrait maintenant réussir. Mais ne nous précipitons pas : ajoutons un test pour vérifier que cette méthode ne retourne que les commentaires de l'article demandé :

php
it('returns comments for a post', function () {
    Comment::factory()
        ->count(2)
        ->create();

    $this->getJson(route('posts.comments', Post::factory()->create()))
        ->assertJson(
            fn (AssertableJson $json) => $json
                ->has('data', 0)
        );
});

Ce test crée deux commentaires pour un article, appelle la route avec un autre article et attend une réponse vide.

Le test échoue, car le contrôleur ne filtre pas encore les commentaires par article.

Corrigeons la méthode index :

php
use App\Models\Post;

final class PostCommentController extends Controller
{
    public function index(Post $post): ResourceCollection
    {
        $comments = Comment::query()
            ->whereBelongsTo($post)
            ->with('user')
            ->get();

        return CommentResource::collection($comments);
    }
}

Le binding de modèle des routes de Laravel nous fournit directement l'instance Post. Comme la route contient {post} et que la méthode accepte un Post, Laravel recherche l'enregistrement correspondant et l'injecte. Le contrôleur reste ainsi concis.

Les tests devraient maintenant réussir. 👌

Passons à la méthode store.

Vous connaissez la méthode : commençons par écrire un test :

php
<?php

use App\Models\Post;

it('requires authentication', function () {
    $post = Post::factory()->create();

    $this->postJson(route('posts.comments.store', $post))
        ->assertUnauthorized();
});

Ce test vérifie que l'authentification est requise. Ajoutez la route :

php
Route::middleware('auth')->group(function () {
    Route::post('/posts/{post}/comments', [PostCommentController::class, 'store'])
        ->name('posts.comments.store');
});

Continuons avec un autre test :

php
use App\Models\User;

// ...

it('stores a comment', function () {
    $user = User::factory()->create();
    $post = Post::factory()->create();

    $this->actingAs($user)
        ->postJson(route('posts.comments.store', $post), [
            'content' => 'This is a comment',
        ]);

    $this->assertDatabaseHas('comments', [
        'post_id' => $post->id,
        'user_id' => $user->id,
        'content' => 'This is a comment',
    ]);
});

Ce test vérifie que le commentaire est enregistré avec le bon post_id, le bon user_id et le bon contenu. Le contrôleur définit les deux clés étrangères.

Nous pouvons maintenant implémenter la méthode store :

php
final class PostCommentController extends Controller
{
    public function store(Request $request, Post $post)
    {
        Comment::create([
            'post_id' => $post->id,
            'user_id' => $request->user()->id,
            'content' => $request->input('content')
        ]);
    }
}

Note

Nous écrivons uniquement le minimum nécessaire pour faire réussir les tests. Cela ne signifie pas que l'implémentation est terminée. Par exemple, la requête doit encore être validée, ce que nous ajouterons avec le prochain test.

Ajoutons un test pour la validation de la requête :

php
it('requires a valid data', function (array $badData, array|string $errors) {
    $post = Post::factory()->create();

    $response = $this->actingAs(User::factory()->create())
        ->postJson(route('posts.comments.store', $post), $badData);

    $response->assertJsonValidationErrors($errors);
})->with([
    [['content' => ''], ['content' => 'The content field is required.']],
    [['content' => 1], ['content' => 'The content field must be a string.']],
    [['content' => 'a'], ['content' => 'The content field must be at least 6 characters.']],
    [['content' => 'a'.str_repeat('a', 2001)], ['content' => 'The content field must not be greater than 2000 characters.']],
]);

Pest exécute ce test quatre fois avec des données différentes. Un dataset permet de couvrir plusieurs entrées invalides sans dupliquer le corps du test.

Créons une classe StoreCommentRequest pour contenir ces règles :

bash
php artisan make:request StoreCommentRequest
php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreCommentRequest extends FormRequest
{
    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'content' => ['required', 'string', 'max:2000', 'min:6'],
        ];
    }
}

Mettez à jour store pour utiliser la nouvelle requête :

php
use App\Http\Requests\StoreCommentRequest; 
use Illuminate\Http\Request; 

final class PostCommentController extends Controller
{
    public function store(StoreCommentRequest $request, Post $post) 
    {
        // ...
    }
}

Laravel valide maintenant la requête avant d'exécuter le contrôleur. ✨

Ajoutons enfin un test pour vérifier que les données retournées sont correctes :

php
it('returns the created comment', function () {
    $user = User::factory()->create();
    $post = Post::factory()->create();
    $comment = Comment::factory()->make();

    $response = $this->actingAs($user)
        ->postJson(route('posts.comments.store', $post), [
            'content' => $comment->content,
        ]);

    $comment = Comment::first();
    $response
        ->assertCreated()
        ->assertJson(
            fn (AssertableJson $json) => $json
                ->has(
                    'data',
                    fn (AssertableJson $json) => $json
                        ->where('id', $comment->id)
                        ->where('content', $comment->content)
                        ->has(
                            'author',
                            fn (AssertableJson $json) => $json
                                ->where('name', $user->name)
                                ->where('username', $user->username)
                                ->where('avatar', $user->avatar)
                        )
                )
        );
});

Ce test vérifie les données retournées et s'assure que le code HTTP est 201 Created grâce à assertCreated.

Mettons à jour la méthode store pour retourner le commentaire créé :

php
use Illuminate\Http\Resources\Json\JsonResource;

final class PostCommentController extends Controller
{
    public function store(StoreCommentRequest $request, Post $post): JsonResource
    {
        $comment = Comment::create([ 
            'post_id' => $post->id,
            'user_id' => $request->user()->id,
            'content' => $request->input('content')
        ]);

        $comment->load('user'); 

        return CommentResource::make($comment); 
    }
}

Tous les tests devraient maintenant réussir.

Ces tests représentent les tests que vous pouvez écrire pour les autres contrôleurs.

Modifier et supprimer des commentaires

Nous pouvons maintenant implémenter la modification et la suppression des commentaires.

Note

Je vais avancer plus vite ici, car le workflow est maintenant familier. Les tests complets sont inclus afin que vous puissiez suivre chaque exigence.

Pour la modification, créons un nouveau fichier de test :

bash
mkdir -p tests/Http/Api/Comment && touch tests/Http/Api/Comment/UpdateTest.php

Ajoutons ensuite le test suivant :

php
<?php

declare(strict_types=1);

use App\Models\Comment;
use App\Models\User;
use Illuminate\Testing\Fluent\AssertableJson;

it('requires authentication', function () {
    $comment = Comment::factory()->create();

    $this->putJson(route('comments.update', $comment))
        ->assertUnauthorized();
});

it('requires the user to be the author of the comment', function () {
    $user = User::factory()->create();
    $comment = Comment::factory()->create();

    $this->actingAs($user)
        ->putJson(route('comments.update', $comment), [
            'content' => 'Updated content',
        ])
        ->assertForbidden();
});

it('updates a comment', function () {
    $user = User::factory()->create();
    $comment = Comment::factory()
        ->for($user)
        ->create();

    $this->actingAs($user)
        ->putJson(route('comments.update', $comment), [
            'content' => 'Updated content',
        ]);

    $this->assertDatabaseHas('comments', [
        'id' => $comment->id,
        'content' => 'Updated content',
    ]);
});

it('requires a valid data', function (array $badData, array|string $errors) {
    $user = User::factory()->create();
    $comment = Comment::factory()->for($user)->create();

    $this->actingAs($user)
        ->putJson(route('comments.update', $comment), $badData)
        ->assertJsonValidationErrors($errors);
})->with([
    [['content' => ''], ['content' => 'The content field is required.']],
    [['content' => 1], ['content' => 'The content field must be a string.']],
    [['content' => 'a'], ['content' => 'The content field must be at least 6 characters.']],
    [['content' => 'a' . str_repeat('a', 2001)], ['content' => 'The content field must not be greater than 2000 characters.']],
]);

it('returns the comment', function () {
    $user = User::factory()->create();
    $comment = Comment::factory()->for($user)->create();

    $this->actingAs($user)
        ->putJson(route('comments.update', $comment), [
            'content' => 'Updated content',
        ])
        ->assertOk()
        ->assertJson(
            fn(AssertableJson $json) => $json
                ->has(
                    'data',
                    fn(AssertableJson $json) => $json
                        ->where('id', $comment->id)
                        ->where('content', 'Updated content')
                        ->has(
                            'author',
                            fn(AssertableJson $json) => $json
                                ->where('name', $user->name)
                                ->where('username', $user->username)
                                ->where('avatar', $user->avatar)
                        )
                )
        );
});

Ajoutons ensuite la route :

php
<?php

use App\Http\Controllers\Api\CommentController; 

Route::middleware('auth')->group(function () {
    Route::put('/comments/{comment}', [CommentController::class, 'update']) 
        ->name('comments.update'); 
});

Créons le contrôleur :

bash
php artisan make:controller Api/CommentController

Implémentons la méthode update :

php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\UpdateCommentRequest;
use App\Http\Resources\CommentResource;
use App\Models\Comment;
use Illuminate\Http\Resources\Json\JsonResource;

final class CommentController extends Controller
{
    public function update(UpdateCommentRequest $request, Comment $comment): JsonResource
    {
        $comment->update($request->validated());
        $comment->load('user');

        return CommentResource::make($comment);
    }
}

Cette méthode a besoin d'une form request dédiée :

bash
php artisan make:request UpdateCommentRequest

Implémentons cette requête :

php
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\Comment;
use Illuminate\Foundation\Http\FormRequest;

/**
 * @property Comment $comment
 */
final class UpdateCommentRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return $this->user()->can('update', $this->comment);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'content' => ['required', 'string', 'max:2000', 'min:6'],
        ];
    }
}

La méthode authorize de la requête vérifie si l'utilisateur courant peut modifier le commentaire. Elle délègue cette décision à une policy, que nous devons créer.

bash
php artisan make:policy CommentPolicy

Implémentons ensuite la policy :

php
<?php

namespace App\Policies;

use App\Models\Comment;
use App\Models\User;

class CommentPolicy
{
    /**
     * Determine whether the user can update the model.
     */
    public function update(User $user, Comment $comment): bool
    {
        return $user->id === $comment->user_id;
    }
}

Le flux de modification est maintenant terminé.

Terminons l'article en ajoutant la suppression d'un commentaire :

php
<?php

declare(strict_types=1);

use App\Models\Comment;
use App\Models\User;

it('requires authentification', function () {
    $comment = Comment::factory()->create();

    $this->deleteJson(route('comments.destroy', $comment))
        ->assertUnauthorized();
});

it('requires the user to be the author of the comment', function () {
    $user = User::factory()->create();
    $comment = Comment::factory()->create();

    $this->actingAs($user)
        ->deleteJson(route('comments.destroy', $comment))
        ->assertForbidden();
});

it('deletes a comment', function () {
    $user = User::factory()->create();
    $comment = Comment::factory()->for($user)->create();

    $this->actingAs($user)
        ->deleteJson(route('comments.destroy', $comment));

    $this->assertDatabaseMissing('comments', [
        'id' => $comment->id,
    ]);
});
php
<?php

// ...

Route::middleware('auth')->group(function () {
    // ...

    Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])
        ->name('comments.destroy');
});
php
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Gate;

final class CommentController extends Controller
{
    // ...

    public function destroy(Comment $comment): Response
    {
        Gate::authorize('delete', $comment);

        $comment->delete();

        return response()->noContent();
    }
}

La façade Gate autorise la suppression. Laravel résout CommentPolicy à partir du modèle transmis à authorize.

php
final class CommentPolicy
{
    // ...

    /**
     * Determine whether the user can delete the model.
     */
    public function delete(User $user, Comment $comment): bool
    {
        return $user->id === $comment->user_id;
    }
}

Le système de commentaires est prêt et les tests nous donnent confiance dans ses principaux comportements sans nécessiter de tout vérifier manuellement.

Et maintenant ?

L'API de commentaires est maintenant en place. Elle permet de lister, créer, modifier et supprimer des commentaires, tandis que les tests décrivent les comportements et les permissions qui protègent ces opérations.

Le développement piloté par les tests a permis de rester concentré : chaque échec indiquait le comportement à ajouter, et les tests terminés nous protègent désormais contre les régressions.

Ensuite, nous connecterons le front-end statique au back-end. Nous générerons les métadonnées des articles pendant le build VitePress et utiliserons Laravel pour synchroniser automatiquement la table posts.

Pd

Merci de me lire ! Je m'appelle Estéban, et j'adore écrire sur le développement web et le parcours humain qui l'entoure.

Je code depuis plusieurs années maintenant, et j'apprends encore de nouvelles choses chaque jour. J'aime partager mes connaissances avec les autres, car j'aurais aimé avoir accès à des ressources aussi claires et complètes lorsque j'ai commencé à apprendre la programmation.

Si vous avez des questions ou souhaitez discuter, n'hésitez pas à commenter ci-dessous ou à me contacter sur Bluesky, X, et LinkedIn.

J'espère que vous avez apprécié cet article et appris quelque chose de nouveau. N'hésitez pas à le partager avec vos amis ou sur les réseaux sociaux, et laissez un commentaire ou une réaction ci-dessous, cela me ferait très plaisir ! Si vous souhaitez soutenir mon travail, vous pouvez me sponsoriser sur GitHub !

Continuer la lectureAutomatiser facilement la synchronisation de votre back-end

Réactions

Discussions

Ajouter un commentaire

Vous devez être connecté pour accéder à cette fonctionnalité.