Debugging a Slow PHP Application: Measurement, Profiling, and Caching
Use measurement, Xdebug profiling, MySQL slow-query analysis, and opcache inspection in that order to find and fix a slow PHP application.
A slow PHP application rarely needs a bigger server. Adding CPU or RAM before you know what is slow hides the problem and wastes money. The right sequence is measurement, profiling, query analysis, and opcode cache verification. In that order. Start by isolating the bottleneck with a cheap timer. Then prove which function or file is guilty with a profiler. Then fix the database queries eating wall-clock time. Finally, confirm the opcache is actually caching. Set memory_limit to the application's peak legitimate usage, not the server's total RAM. A ceiling set too high masks leaks. South Florida user groups draw the line here: Xdebug profiling is professional debugging. var_dump-driven development is not.
Measure First With microtime() Before Any Profiler
Wrap the top and bottom of index.php with microtime(true) calls. Subtract the start from the end and log the result. This costs nothing and tells you whether the problem lives inside PHP or before it: a web server timeout, a TLS handshake, a CDN misconfiguration. If the wall-clock difference is under 50 ms but your browser waits 3 seconds, the bottleneck sits between the client and the server. Move your investigation to the network tab.
When the microtime() delta is high, instrument three or four checkpoints inside the application: after bootstrap, after routing, after the controller action, after the response is built. Each segment gets its own timestamp. The segment that jumps tells you which layer to profile next. A jump between routing and the controller points at a slow database call or an N+1 problem. A jump after the response build points at a template compiling on every call because opcache is misconfigured.
What To Do When The Whole Request Is Slow
When every checkpoint shows a similar delta, suspect a configuration fault. Check that opcache is enabled with opcache_get_status() and confirm the hit rate exceeds 90%. Confirm Xdebug is disabled in production. Inspect the session handler: the default file handler serialises calls per session, so a browser making parallel AJAX requests queues them and the application appears to hang. Switch to a database or Redis session handler if concurrency is the culprit.
Profile With Xdebug To Find The Specific Function Or File
Xdebug gives you a function-level cost breakdown you can act on. Enable profiling in xdebug.ini with xdebug.mode=profile, set xdebug.output_dir to a writable directory, and make one call to the slow endpoint. The tool writes a cachegrind file. Open it in Webgrind, a browser-based viewer that reads cachegrind output without a desktop IDE. Sort by Self Cost, descending. The function at the top consumes the most CPU time inside its own body, excluding its children. That is your target.
Do not sort by Total Cost first. A function that calls sleep(5) tops the Total Cost list, but the fix is to remove the sleep call, not to optimise the caller. Self Cost isolates the actual instruction execution. When you see a function you do not control, like a framework router or an ORM hydration method, look one level up in the call stack. Your code calls it in a loop or with a dataset larger than you intended.
Reading A Cachegrind Dump In 60 Seconds
Load the file in Webgrind. The % column shows the fraction of total time. Click the header to sort by Self %. If any single custom function exceeds 20%, open it. Look for a loop executing more iterations than you expect or a string operation on a payload megabytes in size. The fix is algorithmic. Changing a regex to strpos() saves microseconds. Moving a database call outside a foreach loop saves seconds.
Xdebug Profiling And The Professional Debugging Dividing Line
South Florida PHP meetups treat Xdebug output as the boundary between guessing and knowing. A developer who reaches for var_dump and microtime when a page slows down operates without evidence. The profiler output is the evidence. Every senior job listing in the Miami to West Palm Beach corridor expects you to produce a cachegrind file and walk a colleague through the call tree. The tool records every function entry and exit, counts calls, and measures inclusive and exclusive wall time. The output is deterministic for a given call.
One trap: profiling a single call does not reveal intermittent slowdowns. For a slowdown that happens once per hour, run the profiler in a loop, trigger the endpoint with ab or wrk, and collect multiple cachegrind files. Compare the Self Cost column across runs. The outlier file shows what changed. This is how you catch a slow query that fires only when a cache expires, or a garbage collection pause that lands on an unlucky call.
PHP Slow Query Log MySQL And The N+1 Problem
The MySQL slow query log is your next stop when the profiler shows time spent inside PDO or mysqli calls. Enable it in MySQL 8 with SET GLOBAL slow_query_log = ON and set long_query_time to 1 second. Tail the log during a load test. Queries that appear more than once per call are N+1 candidates. Fix with eager loading: in Laravel, call with('relation') on the query builder. In plain PDO, fetch all parent IDs, run one IN query for the children, and reassemble the tree in PHP. The N+1 problem scales linearly with result set size. A page fast with ten records becomes unusable with a thousand.
For every query in the slow log, run EXPLAIN. Check the type column. ALL means a full table scan. Compare the rows column against the actual result count. If the estimate is off by an order of magnitude, run ANALYZE TABLE to refresh statistics. Add an index covering the WHERE, JOIN, and ORDER BY clauses. A composite index on (status, created_at) is more useful than separate indexes on each column when the query filters by status and sorts by created_at.
Strict Mode And Silent Data Corruption
Develop on MySQL 8 with strict mode on. Deploying to a shared host with strict mode off causes silent data truncation. A string exceeding the column length is cut to fit with no error. An invalid date becomes '0000-00-00'. The application appears to work until a query relying on exact values returns wrong results. Check the host's SQL mode with SELECT @@sql_mode. If STRICT_TRANS_TABLES is absent, demand it or move to a host running a modern default. cPanel's MultiPHP INI editor controls PHP, not MySQL. The fix is in my.cnf or the host's MySQL configuration panel.
Opcache Hit Rate Debugging In Production
Opcache hit rate debugging confirms whether your bytecode cache is working. Call opcache_get_status() from a status page behind a firewall or run php -r 'print_r(opcache_get_status(false));' from the CLI. The hit rate is oomphs:hit_rate. A value below 90% on a production server signals trouble. Common causes: the cache size is too small for the script count, a deploy clears the cache and first calls after deploy miss, or opcache.revalidate_freq is set to 0 and files are checked on every call.
Set opcache.memory_consumption to a value large enough for your application's compiled scripts and opcache.max_accelrated_files to cover every file plus headroom. After a deploy, warm the cache by hitting every route with a script before the load balancer sends traffic. The failure mode is an opcache stampede: if you clear the cache and then serve public traffic, the first few hundred calls all compile the same scripts simultaneously, spiking CPU and causing timeouts. A 30-second warm-up script avoids this.
PHP Performance Debugging And Hosting Reality
PHP performance debugging often collides with hosting reality. Many small business sites run on cPanel shared hosts where the PHP version selector defaults to an older minor release. Confirm the runtime version with phpinfo() or php -v from the terminal. The MultiPHP INI editor lets you set memory_limit and opcache settings per directory, but changes may not survive a cPanel update. Document the expected settings and verify them after every hosting panel upgrade. A memory_limit far above the application's peak masks a slow memory leak that will exhaust the server only under sustained load. Set the ceiling to the application's peak legitimate usage plus 20%, measured over a week of production traffic.
Session File Collision And Concurrency Bottlenecks
The default PHP session handler writes sessions to files and locks the file for the duration of session_start(). A browser making multiple AJAX calls to the same domain queues them. The second call blocks until the first calls session_write_close() or the script ends. The symptom: a page loads in 200 ms on a cold cache but takes 3 seconds when several components load asynchronously. Call session_write_close() immediately after reading the session data you need. For applications requiring concurrent session access, switch to a database or Redis handler that supports read-write locking or per-key locking.
Session file collision is the most common misdiagnosed bottleneck. Developers add more AJAX endpoints to speed up a page and instead slow it down because every endpoint opens the same session file. The fix is not to disable sessions. Close them early and reopen only when writing. Laravel's session middleware holds the lock for the entire call by default; add a middleware that closes the session after authentication checks if the rest of the call is read-only.
Common Questions
What is the first step when a PHP application becomes slow?
Timing with microtime() at the entry point and a few checkpoints inside the application. This isolates the slow layer before you spend hours profiling the wrong component.
Why does Xdebug profiling matter for professional debugging?
The profiler output is deterministic evidence of where time is spent. South Florida PHP user groups treat the ability to read a cachegrind file as the dividing line between guessing with var_dump and professional debugging.
How do I catch an N+1 query problem?
Enable the MySQL slow query log with a 1-second threshold and tail it during a load test. Queries that repeat for every row in a result set are N+1 candidates. Fix with eager loading or a single IN query.
What opcache hit rate indicates a problem?
Below 90% on a production server. Check opcache_get_status() for the cache size, the script count, and the revalidate frequency. A post-deploy stampede can drop the hit rate to zero for the first few hundred calls.
How does the default session handler cause slowdowns?
The file-based handler locks the session file for the entire call. Parallel AJAX calls from the same browser queue up. Call session_write_close() early or switch to a handler that supports concurrent access.