MFormations
Modern PHP Engineering

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 : Off
  • function : compile les fonctions
  • tracing : 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() ou dump() 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