18 KiB
18 KiB
Symfony Performance Optimization Guide
Profiling & Monitoring
Symfony Profiler Configuration
# config/packages/dev/web_profiler.yaml
web_profiler:
toolbar: true
intercept_redirects: false
framework:
profiler:
only_exceptions: false
collect: true
only_master_requests: true
Blackfire Integration
// Install Blackfire
// composer require blackfire/php-sdk
use Blackfire\Client;
class PerformanceService
{
private Client $blackfire;
public function profileOperation(string $name, callable $operation)
{
$config = new \Blackfire\Profile\Configuration();
$config->setTitle($name);
$config->setSamples(10);
$probe = $this->blackfire->createProbe($config);
$result = $operation();
$profile = $this->blackfire->endProbe($probe);
return $result;
}
}
Database Optimization
Query Optimization
namespace App\Repository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\ORM\Query;
class ProductRepository extends ServiceEntityRepository
{
/**
* Optimized query with proper indexing and eager loading
*/
public function findActiveProductsOptimized(): array
{
return $this->createQueryBuilder('p', 'p.id') // Index by ID
->select('p', 'c', 'i', 't') // Select all at once
->leftJoin('p.category', 'c')
->leftJoin('p.images', 'i')
->leftJoin('p.tags', 't')
->where('p.active = :active')
->andWhere('p.stock > :stock')
->setParameter('active', true)
->setParameter('stock', 0)
->orderBy('p.createdAt', 'DESC')
->setMaxResults(100) // Limit results
->getQuery()
->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true) // Force partial loading
->useQueryCache(true) // Use query cache
->useResultCache(true, 3600) // Cache for 1 hour
->getResult();
}
/**
* Use raw SQL for complex queries
*/
public function findProductsWithComplexCalculation(): array
{
$sql = "
SELECT
p.id,
p.name,
p.price,
COUNT(DISTINCT o.id) as order_count,
SUM(oi.quantity) as total_sold,
AVG(r.rating) as avg_rating
FROM product p
LEFT JOIN order_item oi ON oi.product_id = p.id
LEFT JOIN `order` o ON o.id = oi.order_id
LEFT JOIN review r ON r.product_id = p.id
WHERE p.active = 1
GROUP BY p.id
HAVING order_count > 10
ORDER BY total_sold DESC
LIMIT 50
";
$stmt = $this->getEntityManager()->getConnection()->prepare($sql);
return $stmt->executeQuery()->fetchAllAssociative();
}
/**
* Batch processing for large datasets
*/
public function processLargeDataset(\Closure $processor): void
{
$batchSize = 100;
$offset = 0;
while (true) {
$products = $this->createQueryBuilder('p')
->setFirstResult($offset)
->setMaxResults($batchSize)
->getQuery()
->getResult();
if (empty($products)) {
break;
}
foreach ($products as $product) {
$processor($product);
}
$this->getEntityManager()->flush();
$this->getEntityManager()->clear(); // Clear to free memory
$offset += $batchSize;
}
}
}
Database Indexes
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'product')]
#[ORM\Index(name: 'idx_active_stock', columns: ['active', 'stock'])]
#[ORM\Index(name: 'idx_category_active', columns: ['category_id', 'active'])]
#[ORM\Index(name: 'idx_created_at', columns: ['created_at'])]
#[ORM\UniqueConstraint(name: 'uniq_sku', columns: ['sku'])]
class Product
{
#[ORM\Column(length: 100)]
#[ORM\Index] // Single column index
private ?string $sku = null;
// Other properties...
}
Connection Pooling
# config/packages/doctrine.yaml
doctrine:
dbal:
connections:
default:
url: '%env(resolve:DATABASE_URL)%'
# Connection pooling
options:
persistent: true
# Maximum lifetime of a connection
connect_timeout: 10
# Server settings
1002: 'SET sql_mode = TRADITIONAL'
pool:
min_connections: 2
max_connections: 10
max_idle_time: 600
read_replica:
url: '%env(resolve:DATABASE_REPLICA_URL)%'
options:
persistent: true
Caching Strategies
Multi-level Caching
namespace App\Service;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
class CacheService
{
public function __construct(
private TagAwareCacheInterface $cache,
private CacheItemPoolInterface $redisCache,
private CacheItemPoolInterface $apcu
) {}
/**
* Multi-level cache with fallback
*/
public function getWithFallback(string $key, callable $callback, int $ttl = 3600): mixed
{
// Level 1: APCu (fastest, local)
$apcuItem = $this->apcu->getItem($key);
if ($apcuItem->isHit()) {
return $apcuItem->get();
}
// Level 2: Redis (fast, shared)
$redisItem = $this->redisCache->getItem($key);
if ($redisItem->isHit()) {
$value = $redisItem->get();
// Store in APCu for next time
$apcuItem->set($value);
$apcuItem->expiresAfter(300); // 5 minutes in APCu
$this->apcu->save($apcuItem);
return $value;
}
// Level 3: Generate and store in both caches
$value = $callback();
// Store in Redis
$redisItem->set($value);
$redisItem->expiresAfter($ttl);
$this->redisCache->save($redisItem);
// Store in APCu
$apcuItem->set($value);
$apcuItem->expiresAfter(300);
$this->apcu->save($apcuItem);
return $value;
}
/**
* Cache with tags for invalidation
*/
public function getWithTags(string $key, array $tags, callable $callback, int $ttl = 3600): mixed
{
return $this->cache->get($key, function (ItemInterface $item) use ($callback, $tags, $ttl) {
$item->expiresAfter($ttl);
$item->tag($tags);
return $callback();
});
}
/**
* Invalidate by tags
*/
public function invalidateTags(array $tags): void
{
$this->cache->invalidateTags($tags);
}
/**
* Warm cache
*/
public function warmCache(array $keys, callable $generator): void
{
foreach ($keys as $key => $params) {
$this->cache->get($key, function () use ($generator, $params) {
return $generator($params);
});
}
}
}
HTTP Caching
namespace App\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\Cache;
class ProductController extends AbstractController
{
#[Route('/products', name: 'product_list')]
#[Cache(maxage: 3600, public: true, mustRevalidate: true)]
public function index(): Response
{
$response = $this->render('product/index.html.twig');
// Set cache headers
$response->setPublic();
$response->setMaxAge(3600);
$response->setSharedMaxAge(3600);
$response->headers->addCacheControlDirective('must-revalidate', true);
// ETag for validation
$response->setEtag(md5($response->getContent()));
return $response;
}
#[Route('/products/{id}', name: 'product_show')]
public function show(Product $product, Request $request): Response
{
$response = new Response();
// Set ETag
$etag = md5($product->getUpdatedAt()->format('c'));
$response->setEtag($etag);
// Set Last-Modified
$response->setLastModified($product->getUpdatedAt());
// Check if not modified
$response->setNotModified();
if ($response->isNotModified($request)) {
return $response;
}
// Generate content
return $this->render('product/show.html.twig', [
'product' => $product
], $response);
}
}
ESI (Edge Side Includes)
{# templates/base.html.twig #}
<!DOCTYPE html>
<html>
<body>
<header>
{{ render_esi(controller('App\\Controller\\HeaderController::index')) }}
</header>
<main>
{% block body %}{% endblock %}
</main>
<aside>
{{ render_esi(controller('App\\Controller\\SidebarController::popularProducts', {
'max': 5,
'_cache': 3600
})) }}
</aside>
</body>
</html>
Asset Optimization
Webpack Encore Configuration
// webpack.config.js
const Encore = require('@symfony/webpack-encore');
Encore
.setOutputPath('public/build/')
.setPublicPath('/build')
// Enable production optimizations
.enableSingleRuntimeChunk()
.enableIntegrityHashes(Encore.isProduction())
.enableBuildNotifications()
// Split vendor code
.splitEntryChunks()
// Configure optimization
.configureOptimizationSplitChunks((config) => {
config.chunks = 'all';
config.cacheGroups = {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: 20,
name: 'vendors',
enforce: true
},
commons: {
minChunks: 2,
priority: 10,
reuseExistingChunk: true
}
};
})
// Enable compression
.configureCompressionPlugin((options) => {
options.algorithm = 'gzip';
options.test = /\.(js|css|html|svg)$/;
options.threshold = 10240;
options.minRatio = 0.8;
})
// Image optimization
.configureImageRule({
type: 'asset',
maxSize: 4 * 1024 // 4 kb
})
// Enable versioning
.enableVersioning(Encore.isProduction())
// CDN support
.setManifestKeyPrefix('build/')
.configureCdn('https://cdn.example.com')
;
module.exports = Encore.getWebpackConfig();
Lazy Loading Assets
{# Lazy load images #}
<img
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3C/svg%3E"
data-src="{{ asset('images/product.jpg') }}"
loading="lazy"
alt="Product"
class="lazyload"
/>
{# Lazy load scripts #}
<script>
// Dynamic import for code splitting
document.getElementById('load-feature').addEventListener('click', async () => {
const { FeatureModule } = await import('./features/heavy-feature.js');
FeatureModule.init();
});
</script>
{# Preload critical assets #}
<link rel="preload" href="{{ asset('build/app.css') }}" as="style">
<link rel="preload" href="{{ asset('build/app.js') }}" as="script">
<link rel="preload" href="{{ asset('fonts/main.woff2') }}" as="font" type="font/woff2" crossorigin>
PHP Optimization
OPcache Configuration
; php.ini or opcache.ini
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.max_wasted_percentage=10
opcache.validate_timestamps=0
opcache.revalidate_freq=0
opcache.fast_shutdown=1
opcache.enable_file_override=1
opcache.max_file_size=0
opcache.file_cache=/var/cache/opcache
opcache.file_cache_only=0
opcache.file_cache_consistency_checks=0
; Preload Symfony application
opcache.preload=/var/www/config/preload.php
opcache.preload_user=www-data
Preloading Script
// config/preload.php
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
}
// Additional files to preload
$files = [
__DIR__ . '/../src/Entity/',
__DIR__ . '/../src/Repository/',
__DIR__ . '/../src/Service/',
];
foreach ($files as $file) {
if (is_dir($file)) {
foreach (glob($file . '*.php') as $filename) {
opcache_compile_file($filename);
}
} elseif (is_file($file)) {
opcache_compile_file($file);
}
}
Async Processing
Symfony Messenger Optimization
# config/packages/messenger.yaml
framework:
messenger:
transports:
async_priority_high:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queue_name: high_priority
exchange:
name: high_priority
type: direct
retry_strategy:
max_retries: 3
delay: 1000
async_priority_normal:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queue_name: normal_priority
async_priority_low:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queue_name: low_priority
routing:
'App\Message\EmailMessage': async_priority_high
'App\Message\ProcessImage': async_priority_normal
'App\Message\GenerateReport': async_priority_low
buses:
messenger.bus.default:
middleware:
- doctrine_ping_connection
- doctrine_clear_entity_manager
Batch Message Processing
namespace App\MessageHandler;
use App\Message\ProcessOrder;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class ProcessOrderHandler
{
private array $batch = [];
private const BATCH_SIZE = 100;
public function __invoke(ProcessOrder $message): void
{
$this->batch[] = $message;
if (count($this->batch) >= self::BATCH_SIZE) {
$this->processBatch();
}
}
private function processBatch(): void
{
// Process entire batch at once
$orderIds = array_map(fn($msg) => $msg->getOrderId(), $this->batch);
$orders = $this->orderRepository->findByIds($orderIds);
foreach ($orders as $order) {
$this->processOrder($order);
}
$this->entityManager->flush();
$this->batch = [];
}
public function __destruct()
{
if (!empty($this->batch)) {
$this->processBatch();
}
}
}
Server Optimization
PHP-FPM Configuration
; /etc/php/8.1/fpm/pool.d/www.conf
[www]
pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 500
pm.process_idle_timeout = 10s
; Performance tuning
request_terminate_timeout = 30
request_slowlog_timeout = 10s
slowlog = /var/log/php-fpm/slow.log
; Resource limits
rlimit_files = 65536
rlimit_core = unlimited
Nginx Configuration
# /etc/nginx/sites-available/symfony
server {
server_name example.com;
root /var/www/symfony/public;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/json application/javascript application/xml+rss application/rss+xml application/atom+xml image/svg+xml text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype;
# Browser caching
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# PHP-FPM
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
# Performance
fastcgi_buffer_size 128k;
fastcgi_buffers 4 256k;
fastcgi_busy_buffers_size 256k;
fastcgi_temp_file_write_size 256k;
# Cache
fastcgi_cache_bypass $http_pragma $http_authorization;
fastcgi_no_cache $http_pragma $http_authorization;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
internal;
}
location / {
try_files $uri /index.php$is_args$args;
}
}
Monitoring & Metrics
namespace App\Service;
use Prometheus\CollectorRegistry;
use Prometheus\Storage\Redis;
class MetricsService
{
private CollectorRegistry $registry;
public function __construct()
{
Redis::setDefaultOptions([
'host' => '127.0.0.1',
'port' => 6379,
]);
$this->registry = new CollectorRegistry(new Redis());
}
public function recordRequestDuration(string $route, float $duration): void
{
$histogram = $this->registry->getOrRegisterHistogram(
'symfony',
'request_duration_seconds',
'Request duration in seconds',
['route']
);
$histogram->observe($duration, [$route]);
}
public function incrementCounter(string $name, array $labels = []): void
{
$counter = $this->registry->getOrRegisterCounter(
'symfony',
$name,
'Counter for ' . $name,
array_keys($labels)
);
$counter->inc(array_values($labels));
}
}
Performance Checklist
- Enable OPcache with preloading
- Use HTTP caching headers
- Implement database query caching
- Optimize database indexes
- Use CDN for static assets
- Enable Gzip compression
- Minimize and combine assets
- Use async processing for heavy tasks
- Implement lazy loading
- Monitor and profile regularly