The database is usually the bottleneck. Your PHP application might be fast, but a poorly optimized database can slow everything down to a crawl. Users experience timeouts. Pages take 30 seconds to load. Administrators log out in frustration.
Across 20+ years of legacy PHP rescue work, database optimization has consistently been the single biggest performance gain available — often before any application code changes at all. The pattern holds up: roughly 80% of the improvement comes from 20% of the fixes, and that 20% is almost always strategic indexing and query restructuring, not new hardware.
Here is the step-by-step database optimization playbook we use.
Step 1: Establish a baseline—measure your current performance
Enable slow query logging in MySQL:
Add to your MySQL configuration (my.cnf or my.ini):
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow-query.log
long_query_time = 0.5
log_queries_not_using_indexes = 1
This logs all queries taking longer than 0.5 seconds. Run your application under realistic load for several hours to collect data.
Use EXPLAIN to analyze query performance:
For each slow query, use EXPLAIN to see the execution plan:
EXPLAIN SELECT * FROM orders WHERE customer_id = 123 AND created_date > '2025-01-01';
Look for:
- type: ALL — Full table scan (bad). Should be RANGE, INDEX, or UNIQUE
- rows: huge number — Query examining too many rows (indicates missing index)
- key: NULL — No index used (critical problem)
- Extra: Using filesort — Sorting happening in memory instead of using index (slow for large datasets)
Tools for analysis:
- MySQLTuner: Script that analyzes your database configuration and recommends improvements
- Percona Monitoring & Management: Visual interface for slow query analysis
- MySQL Workbench: GUI tool with query analysis and profiling
Step 2: Fix missing indexes
Most slow queries are slow because they lack proper indexes. Adding indexes is often the single most effective optimization.
Which columns need indexes?
Identify and index columns that are:
- Used in WHERE clauses frequently
- Used in JOIN conditions
- Used in ORDER BY clauses
- Used in GROUP BY clauses
- Foreign key columns (always index these)
Indexing strategy:
Single-column indexes: For columns queried individually
ALTER TABLE orders ADD INDEX idx_customer_id (customer_id);
ALTER TABLE orders ADD INDEX idx_created_date (created_date);
Composite indexes: For multi-column queries (order matters!)
ALTER TABLE orders ADD INDEX idx_customer_date (customer_id, created_date);
When creating composite indexes, put the most selective columns first (column that filters out the most rows).
Be careful with indexes:
- Each index slows down INSERT/UPDATE/DELETE operations (they must update the index too)
- Too many indexes (more than 5-7 per table) causes the optimizer to make poor choices
- Large indexes consume memory and disk space
- Regularly remove unused indexes
Find unused indexes:
SELECT * FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_schema != 'mysql' AND count_star = 0 AND index_name != 'PRIMARY'
ORDER BY object_schema, object_name;
Step 3: Optimize query structure
Common query anti-patterns to fix:
Anti-pattern: SELECT *
Bad:
SELECT * FROM users WHERE id = 123;
Good (only fetch columns you need):
SELECT id, email, name FROM users WHERE id = 123;
Fetching unnecessary columns wastes network bandwidth and memory.
Anti-pattern: N+1 queries
Bad:
$users = db_query("SELECT id FROM users LIMIT 100");
foreach ($users as $user) {
$profile = db_query("SELECT * FROM profiles WHERE user_id = " . $user['id']);
}
This runs 101 queries! For 100 users, you execute 101 separate database calls.
Good (use JOIN):
SELECT u.id, u.email, p.bio FROM users u
JOIN profiles p ON u.id = p.user_id LIMIT 100;
Single query, infinitely faster.
Anti-pattern: Unnecessary subqueries
Bad:
SELECT * FROM orders WHERE customer_id IN
(SELECT id FROM customers WHERE country = 'USA');
Good (JOIN):
SELECT o.* FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE c.country = 'USA';
Anti-pattern: LIKE with leading wildcard
Bad:
SELECT * FROM products WHERE name LIKE '%widget%';
This cannot use indexes and scans the entire table.
Better: use full-text search if available, or only use trailing wildcards:
SELECT * FROM products WHERE name LIKE 'widget%';
Step 4: Schema denormalization (selective, when justified)
Use only after profiling shows specific queries as bottlenecks.
Denormalization means storing redundant data to avoid expensive joins:
Example: Instead of joining order + customer + total every time:
Denormalized approach: Store customer_email and order_total directly in orders table. Trade-off: when customer email changes, update multiple rows. But common queries become faster.
Denormalization is complex and increases maintenance burden. Only use if profiling proves the benefit.
Step 5: Implement query result caching
Application-level caching (recommended):
Cache expensive query results in memory:
// Check cache first
$cache_key = 'customer_' . $customer_id;
$customer = cache_get($cache_key);
if (!$customer) {
// Cache miss - run query
$customer = db_query("SELECT * FROM customers WHERE id = " . $customer_id);
cache_set($cache_key, $customer, 3600); // Cache for 1 hour
}
Caching tools: Redis, Memcached, or even file-based caching
Cache invalidation strategy: This is critical. When data changes, invalidate the cache:
// Update customer
db_query("UPDATE customers SET email = 'new@example.com' WHERE id = 123");
// Invalidate the cache
cache_delete('customer_123');
Database-level caching:
MySQL Query Cache (MySQL 5.7 and earlier) can cache query results automatically. However, it has significant overhead and is removed in MySQL 8.0+. For modern versions, rely on application-level caching.
Step 6: Table maintenance and cleanup
Bloat analysis: Old platforms accumulate junk data (deleted records, obsolete columns, unused tables)
// Check table sizes
SELECT table_name, round(((data_length + index_length) / 1024 / 1024), 2) as size_mb
FROM information_schema.tables
WHERE table_schema = 'your_database'
ORDER BY size_mb DESC;
Are there tables you no longer use? Archive or delete them.
Table optimization: Defragment tables after large DELETE operations
OPTIMIZE TABLE large_table;
Step 7: Database server tuning
Critical MySQL settings to review:
- max_connections: Are you hitting connection limits?
- innodb_buffer_pool_size: Should be 50-70% of system RAM for dedicated database servers
- query_cache_size: Disable for MySQL 5.7+ (overhead outweighs benefit)
- log_slow_queries: Enable to identify problematic queries
Use MySQLTuner to get specific recommendations for your server.
When to rebuild instead of optimize
Sometimes optimization is not enough. Consider a database rebuild if:
- Schema is fundamentally broken (poor design, missing primary keys, bad normalization)
- Tables have millions of rows but queries still take 30+ seconds after optimization
- You find hundreds of unused indexes and columns
- Table locks are causing transaction gridlock
In these cases, a fresh database design with data migration is faster than piecemeal optimization.
Performance expectations after optimization
Well-executed database optimization typically delivers:
- Query response time: 50-90% reduction
- Server load: 30-60% reduction
- Database throughput: 2-5x improvement
- User experience: Significantly faster page loads, fewer timeouts
These improvements compound as your platform scales.
Next steps: Database assessment and optimization
If your legacy PHP platform is slow, database optimization should be your first step before considering platform migration. We can audit your database, identify bottlenecks, and implement targeted optimizations.
Is Your Database Slowing You Down?
Get a free database performance assessment. We'll identify bottlenecks and recommend optimization strategy.
Get Database Assessment