Chapitre 10
10 — Performance PHP
10 — Performance PHP
Course : Performance PHP
1. Introduction à la Performance PHP
1.1 Pourquoi la performance ?
La performance d'une application PHP impacte directement l'expérience utilisateur, le SEO (Google Core Web Vitals), les coûts d'infrastructure et la scalabilité. Un site qui met plus de 3 secondes à charger perd 53% de ses visiteurs.
1.2 Goulots d'étranglement courants
- CPU : computation PHP, boucles inefficaces, OpCache mal configuré
- Mémoire : fuites mémoire, collections volumineuses
- I/O : requêtes SQL lentes, appels HTTP externes, lectures fichiers
- Base de données : requêtes N+1, absence d'index, verrouillage
- Session : stockage fichiers vs Redis
- Autoloading : Composer classmap vs PSR-4
1.3 Métriques clés
- TTFB (Time To First Byte) : < 200ms
- Peak memory : < 128 MB par requête
- RPS (Requests Per Second) : dépend de l'infrastructure
- P95/P99 latency : 99% des requêtes < 500ms
- Database query time : < 50ms par requête
2. OpCache
2.1 Cycle de vie des opcodes
PHP compile le code source en opcodes (bytecode) à chaque requête. OpCache stocke ces opcodes en mémoire partagée, éliminant la phase de compilation :
Request → PHP Source → [Lexer → Parser → Compiler] → Opcodes → Execution
↑ Sans OpCache : à chaque requête
↑ Avec OpCache : une seule fois
2.2 Configuration
; php.ini
[opcache]
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.revalidate_freq=2
opcache.fast_shutdown=1
opcache.validate_timestamps=0 ; désactiver en production
opcache.enable_file_override=1
opcache.max_file_size=0 ; aucune limite
opcache.optimization_level=0x7FFFBFFF; tous les niveaux d'optimisation
opcache.blacklist_filename=/etc/php/blacklist.txt
2.3 Preloading (PHP 8.1+)
Le preloading charge des classes en mémoire au démarrage de FPM :
opcache.preload=/var/www/preload.php
opcache.preload_user=www-data
<?php
// preload.php
require __DIR__ . '/vendor/autoload.php';
$classes = [
App\Services\PaymentService::class,
App\Domains\Order\OrderFactory::class,
App\Infrastructure\Cache\RedisCache::class,
// Classes "chaudes" à précharger
];
foreach ($classes as $class) {
if (class_exists($class)) {
echo "Preloaded: $class\n";
}
}
2.4 JIT (Just-In-Time Compilation)
PHP 8.0+ propose un compilateur JIT qui compile les opcodes en code machine :
opcache.jit=tracing
opcache.jit_buffer_size=100M
opcache.jit=1255 ; CRUNCH_CPU + optimize for CPU
Les 4 modes JIT :
0: Offfunction: compile les fonctionstracing: compile les traces (recommandé)1255: mode agnostique CPU
Tester le JIT :
php -r "var_dump(opcache_get_status()['jit']);"
2.5 Surveillance OpCache
// Script de monitoring
$status = opcache_get_status();
$memory = $status['memory_usage'];
echo "Memory Used: " . round($memory['used_memory'] / 1024 / 1024, 2) . " MB\n";
echo "Memory Free: " . round($memory['free_memory'] / 1024 / 1024, 2) . " MB\n";
echo "Memory Wasted: " . round($memory['wasted_memory'] / 1024 / 1024, 2) . " MB\n";
echo "Hit Rate: " . round($status['opcache_statistics']['hits'] /
($status['opcache_statistics']['hits'] + $status['opcache_statistics']['misses']) * 100, 2) . "%\n";
3. Profiling
3.1 Xdebug + Qcachegrind
# Installation Xdebug
pecl install xdebug
[xdebug]
xdebug.mode=profile
xdebug.output_dir=/tmp/profiling
xdebug.profiler_output_name=cachegrind.out.%t.%p
Analyse :
qcachegrind cachegrind.out.12345.6789
Métriques clés : Self Cost (temps passé dans la fonction elle-même), Cumulative Cost (temps total incluant les appels enfants), Calls (nombre d'appels).
3.2 Blackfire
# Installation
composer global require blackfire/blackfire
// Profiling automatisé dans les tests
it('optimizes post listing', function () {
$profile = blackfire()->profile(function () {
$response = $this->getJson('/api/posts');
});
expect($profile->getMain())->toMatch([
'sql.queries.count' => ['<=', 10], // max 10 requêtes SQL
'peak_memory' => ['<=', '50MB'], // max 50 MB
'main.wall_time' => ['<=', '200ms'], // max 200 ms
]);
});
3.3 Tideways
Extension PHP pour le profiling en production avec faible overhead :
use Tideways\Profiler;
Profiler::start();
Profiler::setTransactionName('POST /api/orders');
// ... code métier ...
Profiler::stop();
3.4 Laravel Debugbar
composer require --dev barryvdh/laravel-debugbar
Affiche en temps réel : queries SQL, temps d'exécution, mémoire, vues, sessions.
4. Caching
4.1 Stratégies de cache
┌─────────────────────────────────────────┐
│ Cache Levels │
│ ┌─────────────────────────────────┐ │
│ │ Application Cache (Redis/Memcached) │
│ │ - Sessions │ │
│ │ - Cache queries │ │
│ │ - Full page cache │ │
│ └─────────────────────────────────┘ │
│ ┌─────────────────────────────────┐ │
│ │ HTTP Cache (Varnish/CDN) │ │
│ │ - Public pages │ │
│ │ - Static assets │ │
│ └─────────────────────────────────┘ │
│ ┌─────────────────────────────────┐ │
│ │ OpCode Cache (OpCache) │ │
│ │ - PHP bytecode │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
4.2 Redis
# Installation
composer require predis/predis
// Config .env
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
// Cache simple
Cache::put('user.' . $id, $user, 3600); // 1 heure
$user = Cache::remember('user.' . $id, 3600, fn() => User::find($id));
// Cache tag (Redis/Memcached)
Cache::tags(['users', 'active'])->put('user.' . $id, $user, 3600);
Cache::tags('users')->flush();
// Cache atomique
Cache::lock('processing-order-' . $orderId)->get(function () {
// Opération atomique
});
4.3 APCu
extension=apcu
apc.enabled=1
apc.shm_size=128M
apc.ttl=3600
// APCu pour cache local (par nœud)
$key = 'expensive_calculation_' . md5($input);
$result = apcu_fetch($key);
if ($result === false) {
$result = expensiveCalculation($input);
apcu_store($key, $result, 300); // 5 minutes
}
4.4 HTTP Cache (Laravel)
// Cache-Control headers
Route::middleware('cache.headers:3600;public')->group(function () {
Route::get('/posts', [PostController::class, 'index']);
Route::get('/posts/{post}', [PostController::class, 'show']);
});
// ETag
Route::get('/posts/{post}', function (Post $post) {
$content = $post->toJson();
$etag = md5($content);
return response($content)
->header('ETag', $etag)
->setCache(['public' => true, 'max_age' => 3600]);
});
4.5 Laravel Cache avancé
// Cache avec tags et TTL
Cache::tags(['articles', 'published'])
->remember('articles.list.' . $page, 3600, function () {
return Article::published()->paginate(20);
});
// Cache décorateur pour repositories
class CachedUserRepository implements UserRepositoryInterface
{
public function __construct(
private UserRepositoryInterface $repository,
private Cache $cache
) {}
public function find(int $id): ?User
{
return $this->cache->remember(
"user.$id",
3600,
fn() => $this->repository->find($id)
);
}
public function save(User $user): void
{
$this->repository->save($user);
$this->cache->forget("user.{$user->id}");
}
}
5. Queues (Files d'attente)
5.1 Architecture des queues
┌────────┐ ┌──────────┐ ┌──────────┐
│ Request│───▶│ Queue │───▶│ Worker │───▶ Result
└────────┘ └──────────┘ └──────────┘
│
▼
┌──────────┐
│ Redis / │
│ Database │
└──────────┘
5.2 Laravel Horizon
composer require laravel/horizon
php artisan horizon:install
// config/horizon.php
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['high', 'default', 'low'],
'maxProcesses' => 10,
'balance' => 'auto',
'minProcesses' => 1,
'maxTime' => 0,
'tries' => 3,
],
],
],
// App/Jobs/ProcessPodcast.php
class ProcessPodcast implements ShouldQueue
{
public string $queue = 'high'; // priorité
public function handle(): void
{
// Traitement long
}
}
// Horizon metrics
Horizon::routeMailNotificationsTo('ops@example.com');
Horizon::routeSlackNotificationsTo('#horizon', env('SLACK_WEBHOOK_URL'));
5.3 Job batching et chaînage
// Batch
$batch = Bus::batch([
new ProcessFile($file1),
new ProcessFile($file2),
new ProcessFile($file3),
])->then(function (Batch $batch) {
// Tous les jobs réussis
})->catch(function (Batch $batch, Throwable $e) {
// Un job a échoué
})->finally(function (Batch $batch) {
// Terminé (succès ou échec)
})->dispatch();
// Chaînage
ProcessPayment::withChain([
new SendInvoice,
new NotifyUser,
])->dispatch($order);
5.4 Rate limiting et throttling
// Rate limit par job
class ProcessWebhook implements ShouldQueue
{
public $middleware = [RateLimited::class];
public function handle(): void
{
// Appel API externe
}
}
// middleware personnalisé
class RateLimited
{
public function handle($job, $next): void
{
Redis::throttle('webhooks')
->allow(100)
->every(60)
->then(fn() => $next($job),
fn() => $job->release(10));
}
}
6. Database Performance
6.1 Problème N+1
// ❌ N+1 queries
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // 1 query par post
}
// ✅ Eager loading
$posts = Post::with('author')->get();
// ✅ Lazy eager loading
$posts = Post::all();
$posts->load('author');
// ✅ Nested eager loading
$posts = Post::with(['author.profile', 'comments.user'])->get();
6.2 Indexation
-- ANALYSE DES REQUÊTES LENTES
EXPLAIN SELECT * FROM posts WHERE status = 'published' ORDER BY created_at DESC;
-- INDEX SIMPLE
CREATE INDEX idx_posts_status ON posts(status);
-- INDEX COMPOSITE (ordre des colonnes important !)
CREATE INDEX idx_posts_status_created ON posts(status, created_at);
-- INDEX PARTIEL (PostgreSQL)
CREATE INDEX idx_posts_published ON posts(created_at) WHERE status = 'published';
-- INDEX FULLTEXT (MySQL)
CREATE FULLTEXT INDEX idx_posts_search ON posts(title, content);
6.3 Chunking
// Éviter les memory leaks sur les gros volumes
Post::chunk(100, function (Collection $posts) {
foreach ($posts as $post) {
ProcessPost::dispatch($post);
}
});
// Lazy collections
foreach (Post::lazy() as $post) {
// Traitement sans tout charger en mémoire
}
6.4 Query Builder vs Eloquent
// Eloquent : pratique mais plus lent
$users = User::where('active', true)
->whereHas('orders', fn($q) => $q->where('total', '>', 100))
->get();
// Query Builder : plus performant pour les lectures complexes
$users = DB::table('users')
->join('orders', 'users.id', '=', 'orders.user_id')
->where('users.active', true)
->where('orders.total', '>', 100)
->select('users.*', DB::raw('SUM(orders.total) as total_spent'))
->groupBy('users.id')
->get();
7. Laravel Octane
7.1 Présentation
Octane démarre l'application une fois en mémoire et sert les requêtes via RoadRunner (Go) ou Swoole (C), évitant le boot de Laravel à chaque requête.
composer require laravel/octane
php artisan octane:install
7.2 RoadRunner
# Installation
php artisan octane:install --server=roadrunner
./rr serve
# Production (daemon)
php artisan octane:start --server=roadrunner --host=0.0.0.0 --port=8000 --workers=4
7.3 Précautions Octane
// ❌ État global persistant entre requêtes
class UserController
{
private static array $cache = [];
public function show($id): JsonResponse
{
// Problème : $cache persiste entre les requêtes
if (!isset(self::$cache[$id])) {
self::$cache[$id] = User::find($id);
}
return response()->json(self::$cache[$id]);
}
}
// ✅ Utiliser le service container
class UserController
{
public function __construct(
private UserRepository $repository
) {}
public function show($id): JsonResponse
{
return response()->json($this->repository->find($id));
}
}
7.4 Benchmarks
Apache Bench (1000 requests, 10 concurrent) :
- PHP-FPM : ~150 RPS
- Octane + RoadRunner (4 workers) : ~1200 RPS
- Octane + Swoole (4 workers) : ~1500 RPS
8. Checklist Performance
8.1 Production
- OpCache configuré et vérifié (hit rate > 95%)
- Redis/Memcached pour sessions et cache
- Queues pour tâches longues (Horizon)
- Eager loading systématique
- Index manquants identifiés (explain)
- Assets minifiés et versionnés
- CDN pour fichiers statiques
- HTTP/2 ou HTTP/3
- Octane pour haute charge
8.2 Code
- Pas de
dd()oudump()en production - Éviter les requêtes N+1
- Collections Laravel optimisées (
chunk,lazy) - Cache des configurations (
php artisan config:cache) - Cache des routes (
php artisan route:cache) - Cache des vues (
php artisan view:cache) - Éviter les appels API synchrones dans le cycle requête
8.3 Outils de monitoring
# Laravel Pulse
composer require laravel/pulse
php artisan vendor:publish --provider="Laravel\Pulse\PulseServiceProvider"
php artisan migrate
9. Résumé
La performance PHP est un sujet transverse qui touche à toutes les couches :
- OpCache : indispensable, tuning nécessaire
- JIT : bénéfique pour le calcul intensif
- Profiling : seul moyen fiable d'identifier les goulots
- Caching : stratégie multi-niveaux (OpCache → Redis → HTTP → CDN)
- Queues : décharger le cycle requête/réponse
- Database : index, eager loading, chunking
- Octane : x10 de throughput pour les apps Laravel