MFormations
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

ComposantTechnologie
BackendLaravel 11, PHP 8.3
Admin PanelFilament 3 + Filament Shield
FrontendLivewire 3, Alpine.js, Tailwind CSS
Base de donnéesMySQL 8 / PostgreSQL 16
CacheRedis
SearchMeilisearch
QueueRedis + Horizon
RealtimeLaravel Reverb
APILighthouse GraphQL
TestsPest
CI/CDGitHub Actions + Deployer
ConteneurisationDocker
MonitoringLaravel Pulse
MediaSpatie 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.