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
- Créez une commande Artisan qui nettoie les vieux logs
- Implémentez des global scopes pour multi-tenant
- Créez un job batch pour importer un fichier CSV
- Configurez Horizon avec supervisor et tags
- 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