Some time ago a client approached us to host their existing PHP application and make it faster. Pages were loading slowly, especially in the administration interface. Sometimes taking over 20 seconds to complete. We optimised this applications performance and describe the changes that had the biggest impact.
To illustrate the improvements here’s an example performance report of an overview page in the administration interface.
- The left bar shows the page load time of the old version running on Apache2/PHP 5.5.
- The dark grey bar is the application deployed on Amazon with NGINX and PHP-FPM and optimised OPcache settings. Also we cached the Doctrine2 metadata in Redis.
- The third bar shows the page load after we upgraded to PHP 7.
- And the fourth bar, hard to see, is after we paginated the results of the overview page.
Below we elaborate how we did these changes.
Upgrade to PHP 7
With every release PHPs performance gets better. “Increased performance” is a resident item in the changelog for every major and minor version for the last years.
According to our experience and this infographic from Zend, PHP 7 makes websites twice as fast compared to PHP 5.6.
Since PHP 7 is almost a drop-in replacement, the return of investment when upgrading is high. Definitely recommended.
Optimise OPcache settings
PHP is an interpreted language. This means that every time your PHP-script is executed it is loaded from disk, interpreted and then executed.
In more detail this means the following steps are done:
- Loading: Read script from the filesystem into memory
- Lexing: Script is split up into tokens
- Parsing: These tokens are grouped in a tree structure
- Analysing: The token tree is validated against semantical errors
- Optimising: The token tree is optimised. For example
!falsewill be replaced by
- Compiling: The token tree is translated to code the OS can understand
If you want to know more about this process this Wikipedia page explains it in more detail.
Every time your PHP-script is executed all these steps will be followed. That’s a lot of operations, every time. OPcache stores the result of step 1 to 5 in shared memory for every PHP-script that you run. This makes running your script cheaper in terms of computer resources and time. And therefore a lot faster.
To get the best from OPcache you have to tune its configuration. This tuning is mostly a matter of tweaking settings and measuring the results. Here are some settings that are important factors for application performance:
- opcache.max_accelerated_files: The maximum number of files that OPcache will cache. Each .php file counts as one if it’s loaded in your application. A good indication comes from this one-liner:
find . -type f -iname '*.php' | wc -l
- opcache.validate_timestamps and opcache.revalidate_freq: These settings control how long a compiled file lives in the cache. For production you could set opcache.validate_timestamps to ‘0’ so that the cache will not expire until you clear the cache manually. For example as part of the deployment routine. As an alternative you can set a high value for opcache.revalidate_freq.
If you run PHP via PHP-FPM, you can clear OPcache manually by reloading PHP-FPM.
The performance increase you gain from configuring OPcache properly varies per project, but it will always have a positive effect.
Doctrine2 metadata cache performance
You have to know this application is using Doctrine2 intensively. Doctrine is very easy to use when it comes to the ORM; you define a class and corresponding configuration (called metadata) on how the ORM should map the class to the database and you’re done.
It turns out that fetching and parsing this metadata is quite time-consuming in the default settings, even for production.
By default Doctrine fetches fresh metadata from disk every time your PHP-script is executed. That’s not necessary at all with proper caching and can save your application a lot of time!
In another project we found that storing this metadata cache in Redis is a lot faster compared to the default configuration. So we adopted that strategy here as well. Saving the Doctrine metadata cache in Redis turns out to be a walk in the park.
You can do it by following these steps:
- Get an instance of Redis server running
- Use SncRedisBundle to:
- configure the connection to Redis
- store the Doctrine metadata cache in Redis
The Symfony configuration looks like this:
[gist id=”d87f459ed78ad3abd67191340dc7ca94″ file=”config.yml”]
This gives a performance increase of 19.9% in my tests, from 19.05 to 22.84 requests per second on my development box.
Server-side pagination for overview pages
The original application was paginating recordsets for tables on the client-side. This was working great once the recordset was loaded from the server, but this was exactly the problem. The queries to load the records for the overview are heavy and in one case loading still took about 15 seconds on average. Nobody wants to wait for that.
To reduce the query time we optimised the query a little bit, but the biggest gain — performance from a user experience point of view — was obtained by lowering the amount of results each query produced. In our case there were 10 records shown per page, so we let the query produce 10 results and not the entire set as before.
When we adopted the application the queries were built by using the Doctrine QueryBuilder component, which lets you build queries using PHP code very easily. Changing the query to yield results for a specific page was not that hard.
[gist id=”d87f459ed78ad3abd67191340dc7ca94″ file=”querybuilder.php”]
And as it turns out, Doctrine comes with a Pagination tool for the query builder. This is particularly useful when you also need to retrieve the total amount of records for a query. Which we do, so the client knows how many pages there are in total.
I found the paginator very easy to use:
[gist id=”d87f459ed78ad3abd67191340dc7ca94″ file=”paginator.php”]
While this performance gain isn’t fair to measure in numbers, the user experience did improve a lot. In our case it is the difference between waiting 22 seconds (22.176 ms) and waiting 0.081 seconds (81 ms) for records to appear in the overview. Neat, right?