MFormations
Modern PHP Engineering

Chapitre 7

07 — Laravel

07 — Laravel

Cours 07 — Laravel

07.1 Artisan

Artisan est la CLI de Laravel. Bien plus qu'un générateur de code, c'est un outil de productivité complet.

Commandes essentielles

# Génération de code
php artisan make:model Post -mc          # Modèle + Migration + Controller
php artisan make:model Post -a           # Modèle + tout (factory, seeder, controller, resource)
php artisan make:controller Api/PostController --api
php artisan make:livewire Counter
php artisan make:event OrderShipped
php artisan make:listener SendShipmentNotification
php artisan make:job ProcessPodcast
php artisan make:mail OrderConfirmation
php artisan make:notification InvoicePaid
php artisan make:rule Uppercase
php artisan make:scope ActiveScope
php artisan make:cast JsonCast

# Base de données
php artisan migrate:fresh --seed
php artisan db:seed --class=UserSeeder
php artisan db:show
php artisan db:monitor

# Cache
php artisan optimize                  # Cache config, routes, events
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

Tinker (REPL interactif)

php artisan tinker

# Dans Tinker
>>> User::find(1)
>>> User::where('active', true)->pluck('name')
>>> $user = User::factory()->create()
>>> Cache::put('key', 'value', 3600)
>>> Http::get('https://api.github.com')->json()
>>> dispatch(new ProcessPodcast($podcast))
>>> Str::uuid()
>>> collect([1, 2, 3])->map(fn($n) => $n * 2)

Commandes personnalisées

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;

class GenerateReport extends Command
{
    protected $signature = 'report:generate
                          {type : Type de rapport (users|orders|revenue)}
                          {--date-from= : Date de début}
                          {--date-to= : Date de fin}
                          {--format=csv : Format de sortie (csv|json)}
                          {--output= : Fichier de sortie}';

    protected $description = 'Génère un rapport personnalisé';

    public function handle(): int
    {
        $type = $this->argument('type');
        $from = $this->option('date-from') ?? now()->subMonth()->toDateString();
        $to = $this->option('date-to') ?? now()->toDateString();
        $format = $this->option('format');

        $this->info("Génération du rapport {$type} du {$from} au {$to}...");
        $this->newLine();

        $bar = $this->output->createProgressBar(100);
        $bar->start();

        $data = match ($type) {
            'users' => DB::table('users')
                ->whereBetween('created_at', [$from, $to])
                ->count(),
            'orders' => DB::table('orders')
                ->whereBetween('created_at', [$from, $to])
                ->sum('total'),
            'revenue' => DB::table('orders')
                ->whereBetween('created_at', [$from, $to])
                ->where('status', 'completed')
                ->sum('total'),
            default => throw new \InvalidArgumentException("Type inconnu: {$type}"),
        };

        $bar->finish();
        $this->newLine(2);

        if ($outputPath = $this->option('output')) {
            file_put_contents($outputPath, $data);
            $this->info("Rapport sauvegardé: {$outputPath}");
        } else {
            $this->table(
                ['Type', 'Valeur'],
                [[$type, $data]]
            );
        }

        return Command::SUCCESS;
    }
}

07.2 Eloquent avancé

Global Scopes

<?php

namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class ActiveScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where('active', true);
    }
}

// Ou avec des Anonymous Global Scopes (plus simple)
class User extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope('active', fn(Builder $q) => $q->where('active', true));
    }
}

// Retirer un scope
User::withoutGlobalScope('active')->get();
User::withoutGlobalScopes()->get();

Relations polymorphes

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphToMany;

class Post extends Model
{
    // Polymorphic One-to-Many
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }

    // Polymorphic Many-to-Many (tags)
    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

class Video extends Model
{
    public function comments(): MorphMany
    {
        return $this->morphMany(Comment::class, 'commentable');
    }

    public function tags(): MorphToMany
    {
        return $this->morphToMany(Tag::class, 'taggable');
    }
}

class Comment extends Model
{
    public function commentable(): MorphTo
    {
        return $this->morphTo();
    }
}

// Utilisation
$post = Post::find(1);
foreach ($post->comments as $comment) { }
foreach ($post->tags as $tag) { }

$video = Video::find(1);
foreach ($video->comments as $comment) { }

Subquery selects

<?php

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

// Ajouter une sous-requête dans SELECT
$users = User::query()
    ->addSelect(['last_post_date' => Post::query()
        ->selectRaw('MAX(created_at)')
        ->whereColumn('user_id', 'users.id')
    ])
    ->addSelect(['post_count' => Post::query()
        ->selectRaw('COUNT(*)')
        ->whereColumn('user_id', 'users.id')
    ])
    ->get();

// Order by subquery
$users = User::query()
    ->orderByDesc(Post::query()
        ->selectRaw('COUNT(*)')
        ->whereColumn('user_id', 'users.id')
    )
    ->get();

// whereRelation avec subquery
$users = User::whereRelation('posts', 'created_at', '>', now()->subMonth())
    ->get();

// withAggregate (Laravel 10+)
$users = User::query()
    ->withCount('posts')
    ->withMax('posts', 'created_at as last_post_date')
    ->withExists('posts as has_posts')
    ->get();

withAggregate

<?php

// Laravel 10+ : agrégations dans le with
$users = User::withAggregate('posts', 'title', 'MAX as last_post_title')
    ->get();

echo $users->first()->last_post_title; // null ou titre du dernier post

// Combinations
$users = User::query()
    ->withCount(['posts as published_posts' => fn($q) => $q->where('published', true)])
    ->withSum('orders', 'total')
    ->withAvg('ratings', 'score')
    ->get();

07.3 Events et Listeners

<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderShipped
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        public readonly Order $order,
    ) {}
}
<?php

namespace App\Listeners;

use App\Events\OrderShipped;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;

class SendShipmentNotification implements ShouldQueue
{
    public int $delay = 60; // Queue différée

    public function handle(OrderShipped $event): void
    {
        Log::info('Notification envoi', ['order' => $event->order->id]);
        // Envoyer email, SMS, etc.
    }

    public function failed(OrderShipped $event, \Throwable $e): void
    {
        Log::error('Échec notification', ['error' => $e->getMessage()]);
    }
}
<?php

// App\Providers\EventServiceProvider
use App\Events\OrderShipped;
use App\Listeners\SendShipmentNotification;
use App\Events\UserRegistered;
use App\Listeners\SendWelcomeEmail;

class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        OrderShipped::class => [
            SendShipmentNotification::class,
        ],
        UserRegistered::class => [
            SendWelcomeEmail::class,
        ],
    ];

    protected function boot(): void
    {
        parent::boot();

        // Event subscribers
        Event::subscribe(OrderEventSubscriber::class);
    }
}

07.4 Queues

Jobs

<?php

namespace App\Jobs;

use App\Models\Podcast;
use App\Services\Transcoder;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class ProcessPodcast implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $timeout = 300;        // 5 minutes max
    public int $tries = 3;            // 3 tentatives
    public int $backoff = 5;          // délai entre tentatives (secondes)
    public int $maxExceptions = 2;    // exceptions avant abandon

    public function __construct(
        public readonly Podcast $podcast,
    ) {}

    public function handle(Transcoder $transcoder): void
    {
        $transcoder->process($this->podcast);
    }

    public function failed(\Throwable $e): void
    {
        Log::error('Transcodage échoué', [
            'podcast' => $this->podcast->id,
            'error' => $e->getMessage(),
        ]);
    }
}

// Dispatch
ProcessPodcast::dispatch($podcast);
ProcessPodcast::dispatch($podcast)->onQueue('high');
ProcessPodcast::dispatch($podcast)->delay(now()->addMinutes(10));

Job Middleware

<?php

namespace App\Jobs\Middleware;

use Illuminate\Support\Facades\Redis;

class RateLimited
{
    public function handle(object $job, \Closure $next): void
    {
        Redis::throttle('key')
            ->block(10)   // Attendre max 10s
            ->allow(10)   // 10 jobs
            ->every(60)   // par 60s
            ->then(fn() => $next($job), fn() => $job->release(10));
    }
}

// Dans le Job
public function middleware(): array
{
    return [new RateLimited];
}

Batch

<?php

use App\Jobs\ProcessPodcast;
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;

$batch = Bus::batch([
    new ProcessPodcast($podcast1),
    new ProcessPodcast($podcast2),
    new ProcessPodcast($podcast3),
])->then(function (Batch $batch) {
    // Tous réussis
    Log::info('Batch terminé avec succès');
})->catch(function (Batch $batch, \Throwable $e) {
    // Un échec
    Log::error('Batch partiellement échoué');
})->finally(function (Batch $batch) {
    // Quoi qu'il arrive
    Log::info('Batch terminé');
})->dispatch();

// Suivi
$batch = Bus::findBatch($batchId);
echo $batch->totalJobs;      // 3
echo $batch->pendingJobs;    // 0
echo $batch->failedJobs;     // 0
echo $batch->progress();     // 100%

Chain

<?php

use App\Jobs\OptimizeImage;
use App\Jobs\GenerateThumbnail;
use App\Jobs\UploadToCDN;

$chain = [
    new OptimizeImage($image),
    new GenerateThumbnail($image),
    new UploadToCDN($image),
];

Bus::chain($chain)
    ->onConnection('redis')
    ->onQueue('images')
    ->catch(fn(\Throwable $e) => Log::error('Image chain failed'))
    ->dispatch();

07.5 Horizon

Horizon est un tableau de bord pour les queues Redis de Laravel.

Installation

composer require laravel/horizon
php artisan horizon:install

Configuration

<?php

// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['high', 'default', 'low'],
            'balance' => 'simple',      // auto, simple, false
            'processes' => 3,
            'tries' => 3,
            'timeout' => 300,
        ],
    ],
    'local' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['default'],
            'balance' => 'simple',
            'processes' => 1,
            'tries' => 1,
        ],
    ],
],

'tags' => [
    App\Jobs\ProcessPodcast::class => fn($job) => [
        'podcast:' . $job->podcast->id,
        'user:' . $job->podcast->user_id,
    ],
],

Commandes

php artisan horizon             # Démarrer Horizon
php artisan horizon:status      # Statut
php artisan horizon:pause       # Pause
php artisan horizon:continue    # Reprendre
php artisan horizon:terminate   # Arrêt graceful
php artisan horizon:snapshot    # Métriques
php artisan horizon:clear       # Vider les jobs

07.6 Octane

Octane booste les performances en maintenant Laravel en mémoire entre les requêtes.

Installation

composer require laravel/octane
php artisan octane:install

RoadRunner

# Installation
composer require spiral/roadrunner

# Configuration
php artisan octane:start --server=roadrunner --host=0.0.0.0 --port=8000

# RoadRunner binaire
./vendor/bin/rr get

Swoole

# Extension PHP requise : swoole
php artisan octane:start --server=swoole --host=0.0.0.0 --port=8000

# État
php artisan octane:status
php artisan octane:reload
php artisan octane:stop

Configuration octane

<?php

// config/octane.php
return [
    'server' => env('OCTANE_SERVER', 'roadrunner'),
    'state_file' => storage_path('framework/octane/state.json'),
    'max_requests' => 500,
    'warmup' => [
        App\Http\Middleware\CheckForMaintenanceMode::class,
        \App\Providers\RouteServiceProvider::class,
    ],
];

07.7 Reverb (WebSocket)

Laravel Reverb est un serveur WebSocket natif (Laravel 11+).

composer require laravel/reverb
php artisan reverb:install
<?php

// config/reverb.php
return [
    'apps' => [
        [
            'app_id' => env('REVERB_APP_ID'),
            'key' => env('REVERB_APP_KEY'),
            'secret' => env('REVERB_APP_SECRET'),
            'host' => 'localhost',
            'port' => 8080,
            'scheme' => 'http',
        ],
    ],
];

// Laravel Echo (côté client)
// npm install -D laravel-echo pusher-js

07.8 Filament

Filament est un framework d'administration pour Laravel.

composer require filament/filament
php artisan filament:install --panels
php artisan make:filament-user

Panels

<?php

namespace App\Providers\Filament;

use Filament\Panel;
use Filament\PanelProvider;

class AdminPanelProvider extends PanelProvider
{
    public function panel(Panel $panel): Panel
    {
        return $panel
            ->default()
            ->id('admin')
            ->path('admin')
            ->login()
            ->colors([
                'primary' => '#6366f1',
            ])
            ->font('Inter')
            ->brandName('Mon App')
            ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
            ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
            ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets');
    }
}

Resources

<?php

namespace App\Filament\Resources;

use App\Filament\Resources\UserResource\Pages;
use App\Models\User;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;

class UserResource extends Resource
{
    protected static ?string $model = User::class;

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\Components\TextInput::make('name')
                    ->required()
                    ->maxLength(255),
                Forms\Components\TextInput::make('email')
                    ->email()
                    ->required()
                    ->unique(ignoreRecord: true),
                Forms\Components\Select::make('roles')
                    ->multiple()
                    ->relationship('roles', 'name'),
                Forms\Components\DateTimePicker::make('email_verified_at'),
            ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('name')->searchable(),
                Tables\Columns\TextColumn::make('email')->searchable(),
                Tables\Columns\TextColumn::make('roles.name')->badge(),
                Tables\Columns\IconColumn::make('active')->boolean(),
            ])
            ->filters([
                Tables\Filters\SelectFilter::make('roles')
                    ->relationship('roles', 'name'),
            ])
            ->actions([
                Tables\Actions\EditAction::make(),
                Tables\Actions\DeleteAction::make(),
            ])
            ->bulkActions([
                Tables\Actions\BulkActionGroup::make([
                    Tables\Actions\DeleteBulkAction::make(),
                ]),
            ]);
    }
}

Exercices

  1. Créez une commande Artisan qui nettoie les vieux logs
  2. Implémentez des global scopes pour multi-tenant
  3. Créez un job batch pour importer un fichier CSV
  4. Configurez Horizon avec supervisor et tags
  5. Créez un panel Filament avec ressources et widgets

Références

  • laravel.com/docs
  • laravel.com/docs/horizon
  • laravel.com/docs/octane
  • laravel.com/docs/reverb
  • filamentphp.com/docs