Modern PHP Engineering
Chapitre 16
16 — Projet Fil Rouge : Enterprise CMS
16 — Projet Fil Rouge : Enterprise CMS
Course : Projet Fil Rouge — Enterprise CMS
1. Présentation du Projet
1.1 Contexte
Vous devez construire une plateforme CMS d'entreprise complète. Ce projet fil rouge synthétise tous les concepts abordés dans la formation : architecture, tests, performance, sécurité, DevOps, et bonnes pratiques PHP modernes.
1.2 Stack technique
| Composant | Technologie |
|---|---|
| Backend | Laravel 11, PHP 8.3 |
| Admin Panel | Filament 3 + Filament Shield |
| Frontend | Livewire 3, Alpine.js, Tailwind CSS |
| Base de données | MySQL 8 / PostgreSQL 16 |
| Cache | Redis |
| Search | Meilisearch |
| Queue | Redis + Horizon |
| Realtime | Laravel Reverb |
| API | Lighthouse GraphQL |
| Tests | Pest |
| CI/CD | GitHub Actions + Deployer |
| Conteneurisation | Docker |
| Monitoring | Laravel Pulse |
| Media | Spatie Media Library |
1.3 Modules fonctionnels
┌─────────────────────────────────────────────┐
│ Enterprise CMS │
├───────────┬──────────┬──────────────────────┤
│ Auth │ Content │ Media Library │
│ Multi- │ Manager │ Upload/Resize/ │
│ rôles │ Workflow│ Optimize │
├───────────┼──────────┼──────────────────────┤
│ Search │ SEO │ Notifications │
│ Meilisearch│Meta/Rich│ DB + Reverb │
│ │ Snippets│ │
├───────────┼──────────┼──────────────────────┤
│ Public │ Admin │ Monitoring │
│ API │ Panel │ Pulse │
│ GraphQL │ Filament │ │
└───────────┴──────────┴──────────────────────┘
2. Installation et Configuration
2.1 Création du projet
composer create-project laravel/laravel enterprise-cms "^11"
cd enterprise-cms
# Packages principaux
composer require filament/filament "^3"
composer require filament/spatie-laravel-media-library-plugin "^3"
composer require livewire/livewire "^3"
composer require spatie/laravel-medialibrary "^11"
composer require spatie/laravel-permission "^6"
composer require spatie/laravel-seo "^1"
composer require nuwave/lighthouse "^6"
composer require laravel/horizon "^5"
composer require laravel/pulse "^1"
composer require laravel/reverb "^1"
composer require meilisearch/mmeilisearch-php "^1"
# Dev
composer require --dev pestphp/pest "^3"
composer require --dev pestphp/pest-plugin-laravel "^2"
composer require --dev deployer/deployer "^7"
composer require --dev barryvdh/laravel-debugbar "^3"
2.2 Filament Shield (Multi-rôles)
composer require bezhansalleh/filament-shield
php artisan shield:install
php artisan vendor:publish --tag="filament-shield-config"
php artisan shield:generate
// app/Providers/Filament/AdminPanelProvider.php
use BezhanSalleh\FilamentShield\FilamentShieldPlugin;
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->authMiddleware([Authenticate::class])
->plugins([
FilamentShieldPlugin::make()
->gridColumns([
'default' => 2,
'sm' => 1,
])
->sectionColumnSpan(1),
]);
}
Roles : Super Admin, Admin, Editor, Author, Contributor, Subscriber.
Permissions par module : view, create, update, delete, publish, approve.
2.3 Media Library
// app/Models/Post.php
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class Post extends Model implements HasMedia
{
use InteractsWithMedia;
public function registerMediaCollections(): void
{
$this->addMediaCollection('featured_image')
->singleFile()
->registerMediaConversions(function () {
$this->addMediaConversion('thumb')
->width(150)->height(150)
->sharpen(10);
$this->addMediaConversion('medium')
->width(800)->height(600);
});
$this->addMediaCollection('gallery')
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp']);
}
}
3. Content Management
3.1 Modèles
// app/Models/Post.php
class Post extends Model implements HasMedia
{
use HasFactory, SoftDeletes, InteractsWithMedia;
protected $fillable = [
'title', 'slug', 'content', 'excerpt',
'status', 'published_at', 'author_id',
'category_id', 'template',
];
protected $casts = [
'published_at' => 'datetime',
'meta' => 'array',
'is_featured' => 'boolean',
];
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
public function scopePublished(Builder $query): Builder
{
return $query->where('status', 'published')
->whereNotNull('published_at')
->where('published_at', '<=', now());
}
public function scopeSearch(Builder $query, string $term): Builder
{
return $query->where('title', 'like', "%{$term}%")
->orWhere('content', 'like', "%{$term}%");
}
}
// app/Models/Category.php
class Category extends Model
{
use HasFactory, Sluggable;
protected $fillable = ['name', 'slug', 'description', 'parent_id', 'order'];
public function children(): HasMany
{
return $this->hasMany(Category::class, 'parent_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(Category::class, 'parent_id');
}
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
3.2 Filament Resources
// app/Filament/Resources/PostResource.php
class PostResource extends Resource
{
protected static ?string $model = Post::class;
protected static ?string $navigationIcon = 'heroicon-o-document-text';
protected static ?string $recordTitleAttribute = 'title';
public static function form(Form $form): Form
{
return $form->schema([
Section::make('Contenu')->schema([
TextInput::make('title')->required()->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(fn ($state, Set $set) =>
$set('slug', Str::slug($state))),
TextInput::make('slug')->required()->unique(ignoreRecord: true),
RichEditor::make('content')->required()->columnSpanFull(),
Textarea::make('excerpt')->rows(3)->columnSpanFull(),
])->columns(2),
Section::make('Média')->schema([
SpatieMediaLibraryFileUpload::make('featured_image')
->collection('featured_image')
->conversion('medium'),
SpatieMediaLibraryFileUpload::make('gallery')
->multiple()->collection('gallery'),
]),
Section::make('Organisation')->schema([
Select::make('category_id')
->relationship('category', 'name'),
Select::make('tags')
->multiple()->relationship('tags', 'name'),
Select::make('author_id')
->relationship('author', 'name')
->default(fn() => auth()->id()),
Select::make('status')
->options([
'draft' => 'Brouillon',
'review' => 'En relecture',
'published' => 'Publié',
])->required(),
DateTimePicker::make('published_at'),
])->columns(2),
Section::make('SEO')->schema([
TextInput::make('meta_title')->maxLength(70),
Textarea::make('meta_description')->rows(2)->maxLength(160),
TagsInput::make('meta_keywords'),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('title')->searchable()->sortable()
->limit(50),
TextColumn::make('status')
->badge()
->color(fn(string $state): string => match ($state) {
'draft' => 'gray',
'review' => 'warning',
'published' => 'success',
}),
TextColumn::make('author.name'),
TextColumn::make('category.name'),
TextColumn::make('published_at')->dateTime()->sortable(),
])
->filters([
SelectFilter::make('status')->options([...]),
SelectFilter::make('category')->relationship('category', 'name'),
])
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [
RelationManagers\TagsRelationManager::class,
];
}
}
3.3 Workflow de publication
// App\Filament\Resources\PostResource\Pages\EditPost.php
class EditPost extends EditRecord
{
protected function mutateFormDataBeforeSave(array $data): array
{
if ($data['status'] === 'published' && empty($data['published_at'])) {
$data['published_at'] = now();
}
return $data;
}
protected function afterSave(): void
{
if ($this->record->status === 'published') {
// Notification aux abonnés
event(new PostPublished($this->record));
// Indexation Meilisearch
$this->record->searchable();
}
}
}
4. Recherche Meilisearch
4.1 Configuration
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=masterKey
// config/scout.php
return [
'driver' => env('SCOUT_DRIVER', 'meilisearch'),
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY', null),
],
];
4.2 Searchable Model
use Laravel\Scout\Searchable;
class Post extends Model
{
use Searchable;
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'content' => strip_tags($this->content),
'excerpt' => $this->excerpt,
'category' => $this->category?->name,
'tags' => $this->tags->pluck('name')->join(', '),
'author' => $this->author?->name,
'status' => $this->status,
'published_at' => $this->published_at?->timestamp,
];
}
}
4.3 Frontend de recherche
class SearchPosts extends Component
{
#[Url(history: true)]
public string $query = '';
public function render(): View
{
$posts = [];
if (strlen($this->query) >= 2) {
$posts = Post::search($this->query)
->where('status', 'published')
->paginate(20);
}
return view('livewire.search-posts', [
'posts' => $posts,
]);
}
}
5. SEO
5.1 Configuration SEO
// app/Models/Post.php
class Post extends Model
{
// Spatie Laravel SEO
public function getDynamicSeoData(): SeoData
{
return SeoData::from([
'title' => $this->meta_title ?: $this->title,
'description' => $this->meta_description ?: $this->excerpt,
'image' => $this->getFirstMediaUrl('featured_image'),
'author' => $this->author?->name,
'published_time' => $this->published_at,
'type' => 'article',
]);
}
}
5.2 Sitemap
// routes/web.php
Route::get('/sitemap.xml', function () {
$posts = Post::published()->latest()->get();
$categories = Category::has('posts')->get();
return response()->view('seo.sitemap', [
'posts' => $posts,
'categories' => $categories,
])->header('Content-Type', 'application/xml');
});
6. API GraphQL (Lighthouse)
6.1 Configuration
// app/Providers/AppServiceProvider.php
use Nuwave\Lighthouse\WhereConditions\Schema\LighthouseSchema;
// graphql/schema.graphql
type Query {
posts(status: String @eq, page: Int @paginate): [Post!]!
@paginate(defaultCount: 15, maxCount: 50)
post(id: ID! @eq): Post @find
searchPosts(query: String!): [Post!]!
@field(resolver: "App\\GraphQL\\Queries\\SearchPosts")
}
type Post {
id: ID!
title: String!
slug: String!
content: String!
excerpt: String
status: String!
published_at: DateTime
author: User @belongsTo
category: Category @belongsTo
tags: [Tag!] @belongsToMany
featured_image: String
@field(resolver: "App\\GraphQL\\Queries\\FeaturedImage")
created_at: DateTime!
updated_at: DateTime!
}
type Mutation {
createPost(input: CreatePostInput! @spread): Post!
@create
@middleware(checks: ["auth:sanctum"])
updatePost(id: ID!, input: UpdatePostInput! @spread): Post!
@update
@can(ability: "update", find: "id")
deletePost(id: ID!): Post
@delete
@can(ability: "delete", find: "id")
}
input CreatePostInput {
title: String! @rules(apply: ["required", "max:255"])
content: String! @rules(apply: ["required"])
status: String! @rules(apply: ["in:draft,review,published"])
category_id: ID
tags: CreateTagsRelation
}
7. Tests (Pest)
7.1 Tests unitaires
it('generates slug from title', function () {
$post = Post::factory()->create(['title' => 'Modern PHP Engineering']);
expect($post->slug)->toBe('modern-php-engineering');
});
it('filters published posts', function () {
Post::factory()->count(3)->published()->create();
Post::factory()->count(2)->draft()->create();
expect(Post::published()->count())->toBe(3);
});
7.2 Tests d'API
it('creates a post via GraphQL', function () {
$user = User::factory()->admin()->create();
$category = Category::factory()->create();
$response = $this
->actingAs($user)
->graphQL('
mutation ($input: CreatePostInput!) {
createPost(input: $input) {
id
title
status
}
}
', [
'input' => [
'title' => 'Test Post',
'content' => 'Content',
'status' => 'draft',
'category_id' => $category->id,
],
]);
$response->assertJson([
'data' => [
'createPost' => [
'title' => 'Test Post',
'status' => 'draft',
],
],
]);
});
7.3 Tests Filament
it('displays post list', function () {
Post::factory()->count(5)->create();
$this->actingAs(User::factory()->admin()->create())
->get('/admin/posts')
->assertOk()
->assertSeeLivewire(PostResource\Pages\ListPosts::class);
});
it('validates post creation', function () {
$this->actingAs(User::factory()->admin()->create())
->post('/admin/posts', [])
->assertSessionHasErrors(['title', 'content']);
});
8. Déploiement (Deployer + Docker + CI/CD)
8.1 Docker Compose
services:
app:
build: .
ports: ["80:80"]
depends_on: [database, cache, meilisearch]
database:
image: mysql:8.3
environment:
MYSQL_DATABASE: enterprise_cms
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
volumes: [db_data:/var/lib/mysql]
cache:
image: redis:7-alpine
volumes: [redis_data:/data]
meilisearch:
image: getmeili/meilisearch:v1.6
environment:
MEILI_MASTER_KEY: ${MEILISEARCH_KEY}
volumes: [meili_data:/meili_data]
reverb:
build: .
command: php artisan reverb:start
ports: ["8080:8080"]
depends_on: [database, cache]
horizon:
build: .
command: php artisan horizon
depends_on: [database, cache]
volumes:
db_data: redis_data: meili_data:
8.2 GitHub Actions CI/CD
name: Enterprise CMS CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: 8.3
extensions: mbstring, pdo_mysql, bcmath, redis
- run: cp .env.example .env
- run: composer install --no-interaction
- run: php artisan key:generate
- run: ./vendor/bin/pint --test
- run: ./vendor/bin/phpstan analyse --level=max
- run: php artisan test
deploy:
needs: quality
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: 8.3
- run: composer install --no-dev --optimize-autoloader
- run: npm ci && npm run build
- run: php vendor/bin/dep deploy production
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
9. Résumé du Projet
L'Enterprise CMS est un projet complet qui couvre :
- Architecture : Service Layer, Action Pattern, SOLID
- Admin : Filament 3 avec Shield pour la gestion multi-rôles
- Media : Spatie Media Library avec conversions d'images
- Search : Meilisearch pour la recherche full-text
- SEO : meta tags, sitemap, Open Graph
- API : GraphQL avec Lighthouse
- Realtime : Reverb pour notifications temps réel
- Tests : Pest pour tests unitaires, feature, et navigateur
- DevOps : Docker, Deployer, GitHub Actions, Pulse
Ce projet est conçu pour être la référence de votre portfolio d'ingénieur PHP moderne.