Objectif

Construire un Blog CMS complet en Laravel 11 qui intègre tous les concepts de la formation : Eloquent ORM avec relations complexes, API REST sécurisée avec Sanctum, RBAC multi-rôles, Blade + Livewire, queues et notifications, tests PHPUnit/Pest, et déploiement Docker.

DurΓ©e estimΓ©e : 8 Γ  12 heures. Ce projet est pensΓ© pour Γͺtre rΓ©alisΓ© en autonomie aprΓ¨s avoir suivi les 8 modules. Tous les concepts nΓ©cessaires ont Γ©tΓ© vus en cours.
Module utilisΓ©Dans le projet
01 β€” PHP 8.xEnums pour status/priority, readonly models, match() dans les scopes
02 β€” FondamentauxRoutes versionnΓ©es, controllers resourceful, commands Artisan
03 β€” BladeLayout + composants rΓ©utilisables, Livewire pour la recherche
04 β€” Eloquent5 models liΓ©s, factories, seeders, observers, scopes
05 β€” AuthRBAC 4 rΓ΄les, Policies, Sanctum API tokens
06 — API RESTv1 complète, Resources, Form Requests, pagination cursor
07 β€” TestsSuite complΓ¨te PHPUnit + Pest, couverture β‰₯ 80 %
08 β€” DeployDocker multi-stage, queue workers, GitHub Actions CI/CD

Stack technique

ComposantTechnologieVersion
BackendLaravel11.x
LangagePHP8.3+
Base de donnΓ©esMySQL8.0
Cache / QueueRedis7.x
AuthentificationLaravel Sanctum3.x
FrontendBlade + Livewire3.x
TestsPHPUnit + Pest11 / 2.x
ConteneurisationDocker + nginxalpine
CI/CDGitHub Actionsβ€”
# Setup rapide
composer create-project laravel/laravel blog-cms
cd blog-cms

# Packages requis
composer require laravel/sanctum spatie/laravel-permission livewire/livewire
composer require dedoc/scramble  # documentation API auto

# Packages dev
composer require --dev pestphp/pest pestphp/pest-plugin-laravel barryvdh/laravel-debugbar

# Installation
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan livewire:publish --config

# BDD
php artisan migrate:fresh --seed
php artisan serve

Architecture du projet

app/
β”œβ”€β”€ Console/Commands/
β”‚   β”œβ”€β”€ PublishScheduledArticlesCommand.php
β”‚   └── CleanupDraftArticlesCommand.php
β”œβ”€β”€ Events/
β”‚   └── ArticlePublished.php
β”œβ”€β”€ Http/
β”‚   β”œβ”€β”€ Controllers/
β”‚   β”‚   β”œβ”€β”€ ArticleController.php      # Web (Blade)
β”‚   β”‚   └── Api/V1/
β”‚   β”‚       β”œβ”€β”€ AuthController.php
β”‚   β”‚       β”œβ”€β”€ ArticleController.php  # API JSON
β”‚   β”‚       └── CommentController.php
β”‚   β”œβ”€β”€ Middleware/
β”‚   β”‚   └── CheckRole.php
β”‚   └── Resources/
β”‚       β”œβ”€β”€ ArticleResource.php
β”‚       └── CommentResource.php
β”œβ”€β”€ Jobs/
β”‚   └── SendNewsletterJob.php
β”œβ”€β”€ Listeners/
β”‚   └── NotifySubscribersOnPublish.php
β”œβ”€β”€ Livewire/
β”‚   └── SearchArticles.php
β”œβ”€β”€ Models/
β”‚   β”œβ”€β”€ User.php
β”‚   β”œβ”€β”€ Article.php
β”‚   β”œβ”€β”€ Category.php
β”‚   β”œβ”€β”€ Tag.php
β”‚   └── Comment.php
β”œβ”€β”€ Notifications/
β”‚   └── ArticlePublishedNotification.php
β”œβ”€β”€ Observers/
β”‚   └── ArticleObserver.php
└── Policies/
    β”œβ”€β”€ ArticlePolicy.php
    └── CommentPolicy.php

routes/
β”œβ”€β”€ web.php
β”œβ”€β”€ api.php β†’ api_v1.php
console.php

resources/views/
β”œβ”€β”€ layouts/app.blade.php
β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ article-card.blade.php
β”‚   β”œβ”€β”€ badge.blade.php
β”‚   └── alert.blade.php
β”œβ”€β”€ articles/
β”‚   β”œβ”€β”€ index.blade.php
β”‚   β”œβ”€β”€ show.blade.php
β”‚   β”œβ”€β”€ create.blade.php
β”‚   └── edit.blade.php
└── livewire/
    └── search-articles.blade.php

tests/
β”œβ”€β”€ Unit/Models/ArticleTest.php
β”œβ”€β”€ Feature/Api/ArticleApiTest.php
└── Feature/ArticleWebTest.php

FonctionnalitΓ©s Γ  implΓ©menter

Frontend Blade

  • Page d'accueil avec les 5 derniers articles publiΓ©s (mis en cache 1h)
  • Liste paginΓ©e des articles avec filtres (catΓ©gorie, tag, auteur)
  • Composant Livewire de recherche en temps rΓ©el
  • Formulaire de crΓ©ation/Γ©dition d'article (WYSIWYG simulΓ© avec textarea)
  • Gestion des commentaires avec approbation admin

API REST v1

  • Auth : register / login / logout / me
  • Articles : CRUD complet avec filtres et cursor pagination
  • Comments : crΓ©ation + modΓ©ration
  • Documentation auto via Scramble sur /docs/api

Backend

  • RBAC 4 rΓ΄les : user, author, editor, admin
  • ArticleObserver : slug auto, cascade delete
  • Planification : publication diffΓ©rΓ©e des articles (scheduled_at)
  • Queue : envoi newsletter aux abonnΓ©s Γ  la publication
  • Notification : email aux subscribers via Laravel Notifications
  • Cache : liste homepage invalidΓ©e Γ  chaque publication

Tests & DΓ©ploiement

  • Suite PHPUnit : 30+ tests, couverture β‰₯ 80 %
  • GitHub Actions : tests + couverture sur chaque PR
  • Docker Compose : app + nginx + mysql + redis + queue

SchΓ©ma de base de donnΓ©es

users                     articles
─────────────────         ─────────────────────────
id                        id
name                      title
email (unique)            slug (unique)
password                  excerpt
role (enum)               body
subscribed (bool)         status (enum: draft|published|scheduled|archived)
email_verified_at         is_featured (bool)
                          published_at (nullable)
                          scheduled_at (nullable)
                          author_id β†’ users
                          category_id β†’ categories (nullOnDelete)
                          views_count (int, default 0)
                          created_at, updated_at, deleted_at

categories                tags                    comments
─────────────────         ─────────────────       ─────────────────────
id                        id                      id
name                      name                    body
slug (unique)             color                   article_id β†’ articles
description               created_at              user_id β†’ users
parent_id (self join)                             approved (bool, default false)
created_at                article_tag (pivot)     created_at, updated_at, deleted_at
                          ────────────────
                          article_id
                          tag_id
                          position

API Endpoints v1

MΓ©thodeRouteAuthDescription
POST/api/v1/auth/registerβ€”Inscription
POST/api/v1/auth/loginβ€”Connexion β†’ token
POST/api/v1/auth/logoutBearerRΓ©vocation token
GET/api/v1/auth/meBearerProfil utilisateur
GET/api/v1/articlesβ€”Liste paginΓ©e (cursor)
POST/api/v1/articlesBearer + writeCrΓ©er
GET/api/v1/articles/{slug}β€”DΓ©tail + incr. views
PUT/api/v1/articles/{id}Bearer + writeModifier
DELETE/api/v1/articles/{id}BearerSupprimer (soft)
GET/api/v1/articles/{id}/commentsβ€”Commentaires approuvΓ©s
POST/api/v1/articles/{id}/commentsBearerAjouter commentaire
DELETE/api/v1/comments/{id}BearerSupprimer commentaire
PATCH/api/v1/comments/{id}/approveBearer + editorApprouver

Solution β€” Fichiers clΓ©s

// app/Models/Article.php — version complète
namespace App\Models;

use App\Enums\ArticleStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;

class Article extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'title', 'slug', 'excerpt', 'body', 'status', 'is_featured',
        'published_at', 'scheduled_at', 'author_id', 'category_id', 'views_count',
    ];

    protected $casts = [
        'status'       => ArticleStatus::class,
        'is_featured'  => 'boolean',
        'published_at' => 'datetime',
        'scheduled_at' => 'datetime',
    ];

    // ── Accesseurs ────────────────────────────────────────
    public function readingTime(): Attribute {
        return Attribute::get(
            fn() => max(1, ceil(str_word_count(strip_tags($this->body)) / 200)) . ' min'
        );
    }

    // ── Relations ─────────────────────────────────────────
    public function author()   { return $this->belongsTo(User::class, 'author_id'); }
    public function category() { return $this->belongsTo(Category::class); }
    public function tags()     { return $this->belongsToMany(Tag::class)->withPivot('position')->withTimestamps(); }
    public function comments() { return $this->hasMany(Comment::class); }
    public function approvedComments() {
        return $this->hasMany(Comment::class)->where('approved', true);
    }

    // ── Scopes ────────────────────────────────────────────
    public function scopePublished(Builder $q): void {
        $q->where('status', ArticleStatus::Published)->whereNotNull('published_at');
    }
    public function scopeFeatured(Builder $q): void { $q->where('is_featured', true); }
    public function scopeScheduled(Builder $q): void {
        $q->where('status', ArticleStatus::Scheduled)->where('scheduled_at', '<=', now());
    }

    public function isPublished(): bool {
        return $this->status === ArticleStatus::Published;
    }
}
// app/Enums/ArticleStatus.php
namespace App\Enums;

enum ArticleStatus: string
{
    case Draft     = 'draft';
    case Published = 'published';
    case Scheduled = 'scheduled';
    case Archived  = 'archived';

    public function label(): string {
        return match($this) {
            self::Draft     => 'Brouillon',
            self::Published => 'PubliΓ©',
            self::Scheduled => 'PlanifiΓ©',
            self::Archived  => 'ArchivΓ©',
        };
    }

    public function color(): string {
        return match($this) {
            self::Draft     => 'gray',
            self::Published => 'green',
            self::Scheduled => 'blue',
            self::Archived  => 'yellow',
        };
    }

    public function canTransitionTo(self $new): bool {
        return match($this) {
            self::Draft     => in_array($new, [self::Published, self::Scheduled]),
            self::Scheduled => in_array($new, [self::Published, self::Draft]),
            self::Published => $new === self::Archived,
            self::Archived  => $new === self::Draft,
        };
    }
}
// app/Http/Controllers/Api/V1/ArticleController.php
namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Http\Requests\Api\StoreArticleRequest;
use App\Http\Resources\ArticleResource;
use App\Models\Article;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class ArticleController extends Controller
{
    public function index(Request $request) {
        $articles = Article::with(['author', 'category', 'tags'])
            ->published()
            ->when($request->category, fn($q) => $q->whereHas('category', fn($c) => $c->where('slug', $request->category)))
            ->when($request->tag,      fn($q) => $q->whereHas('tags', fn($t) => $t->where('name', $request->tag)))
            ->when($request->search,   fn($q) => $q->where('title', 'like', "%{$request->search}%"))
            ->latest('published_at')
            ->cursorPaginate(15);

        return ArticleResource::collection($articles);
    }

    public function store(StoreArticleRequest $request) {
        $article = Article::create([
            ...$request->validated(),
            'author_id' => $request->user()->id,
        ]);

        if ($request->tags) {
            $article->tags()->sync($request->tags);
        }

        Cache::tags(['articles'])->flush();

        return (new ArticleResource($article))->response()->setStatusCode(201);
    }

    public function show(string $slug) {
        $article = Article::with(['author', 'category', 'tags', 'approvedComments.user'])
            ->where('slug', $slug)
            ->published()
            ->firstOrFail();

        // IncrΓ©menter le compteur de vues de faΓ§on atomique
        $article->increment('views_count');

        return new ArticleResource($article);
    }

    public function update(StoreArticleRequest $request, Article $article) {
        $this->authorize('update', $article);
        $article->update($request->validated());

        if ($request->has('tags')) {
            $article->tags()->sync($request->tags);
        }

        Cache::tags(['articles'])->flush();

        return new ArticleResource($article);
    }

    public function destroy(Article $article) {
        $this->authorize('delete', $article);
        $article->delete();
        Cache::tags(['articles'])->flush();
        return response()->noContent();
    }
}
// app/Console/Commands/PublishScheduledArticlesCommand.php
namespace App\Console\Commands;

use App\Enums\ArticleStatus;
use App\Events\ArticlePublished;
use App\Models\Article;
use Illuminate\Console\Command;

class PublishScheduledArticlesCommand extends Command
{
    protected $signature   = 'articles:publish-scheduled';
    protected $description = 'Publie les articles dont la date de planification est passΓ©e';

    public function handle(): int {
        $count = 0;

        Article::scheduled()->each(function (Article $article) use (&$count) {
            $article->update([
                'status'       => ArticleStatus::Published,
                'published_at' => $article->scheduled_at,
                'scheduled_at' => null,
            ]);

            ArticlePublished::dispatch($article);
            $count++;
        });

        $this->info("{$count} articles publiΓ©s.");
        return self::SUCCESS;
    }
}

// routes/console.php
Schedule::command('articles:publish-scheduled')->everyMinute()->withoutOverlapping();
// tests/Feature/Api/ArticleApiTest.php
namespace Tests\Feature\Api;

use App\Enums\ArticleStatus;
use App\Models\Article;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;

class ArticleApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_list_returns_only_published_articles(): void {
        Article::factory()->count(3)->published()->create();
        Article::factory()->count(2)->create(['status' => ArticleStatus::Draft]);

        $this->getJson('/api/v1/articles')
            ->assertOk()
            ->assertJsonCount(3, 'data');
    }

    public function test_show_increments_views_count(): void {
        $article = Article::factory()->published()->create(['views_count' => 5]);

        $this->getJson("/api/v1/articles/{$article->slug}")->assertOk();

        $this->assertDatabaseHas('articles', [
            'id'          => $article->id,
            'views_count' => 6,
        ]);
    }

    public function test_author_can_create_article(): void {
        $author = User::factory()->create(['role' => 'author']);

        $this->actingAs($author, 'sanctum')
            ->postJson('/api/v1/articles', [
                'title'  => 'Mon article de test',
                'body'   => str_repeat('word ', 500),
                'status' => 'draft',
            ])->assertCreated();
    }

    public function test_cache_is_invalidated_on_publish(): void {
        Cache::tags(['articles'])->put('test_key', 'value', 3600);
        $author  = User::factory()->create(['role' => 'author']);
        $article = Article::factory()->create(['author_id' => $author->id, 'status' => 'draft']);

        $this->actingAs($author, 'sanctum')
            ->putJson("/api/v1/articles/{$article->id}", ['status' => 'published']);

        $this->assertFalse(Cache::tags(['articles'])->has('test_key'));
    }
}
← Module 08 🧠 QCM Final