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.x | Enums pour status/priority, readonly models, match() dans les scopes |
| 02 β Fondamentaux | Routes versionnΓ©es, controllers resourceful, commands Artisan |
| 03 β Blade | Layout + composants rΓ©utilisables, Livewire pour la recherche |
| 04 β Eloquent | 5 models liΓ©s, factories, seeders, observers, scopes |
| 05 β Auth | RBAC 4 rΓ΄les, Policies, Sanctum API tokens |
| 06 β API REST | v1 complΓ¨te, Resources, Form Requests, pagination cursor |
| 07 β Tests | Suite complΓ¨te PHPUnit + Pest, couverture β₯ 80 % |
| 08 β Deploy | Docker multi-stage, queue workers, GitHub Actions CI/CD |
Stack technique
| Composant | Technologie | Version |
|---|---|---|
| Backend | Laravel | 11.x |
| Langage | PHP | 8.3+ |
| Base de donnΓ©es | MySQL | 8.0 |
| Cache / Queue | Redis | 7.x |
| Authentification | Laravel Sanctum | 3.x |
| Frontend | Blade + Livewire | 3.x |
| Tests | PHPUnit + Pest | 11 / 2.x |
| Conteneurisation | Docker + nginx | alpine |
| CI/CD | GitHub 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Γ©thode | Route | Auth | Description |
|---|---|---|---|
| POST | /api/v1/auth/register | β | Inscription |
| POST | /api/v1/auth/login | β | Connexion β token |
| POST | /api/v1/auth/logout | Bearer | RΓ©vocation token |
| GET | /api/v1/auth/me | Bearer | Profil utilisateur |
| GET | /api/v1/articles | β | Liste paginΓ©e (cursor) |
| POST | /api/v1/articles | Bearer + write | CrΓ©er |
| GET | /api/v1/articles/{slug} | β | DΓ©tail + incr. views |
| PUT | /api/v1/articles/{id} | Bearer + write | Modifier |
| DELETE | /api/v1/articles/{id} | Bearer | Supprimer (soft) |
| GET | /api/v1/articles/{id}/comments | β | Commentaires approuvΓ©s |
| POST | /api/v1/articles/{id}/comments | Bearer | Ajouter commentaire |
| DELETE | /api/v1/comments/{id} | Bearer | Supprimer commentaire |
| PATCH | /api/v1/comments/{id}/approve | Bearer + editor | Approuver |
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'));
}
}