We will continue our performance series with Symfony (previously on Doctrine ORM and PHP). This blog post describes some of the fundamental aspects that affect Symfony performance at the core of HttpKernel request lifecycle. These complement the Symfony Performance docs, which mentions general tips such as Bytecode Caching and Autoloader Optimizations.
Even though Symfony is consistently listed as one of the slower PHP frameworks in many simple hello world benchmarks (most notably Techempower) you can build scalable and performant applications with it. The trick is to keep the baseline performance of your application as small as necessary.
The five influences in this blog posts are by no means the only thing you can look for in Symfony applications, but they are important.
Disclaimer: There is no black and white in these suggestions, they will always affect the performance of your application no matter what. This blog post wants to make you aware of the influence. As a result you get a checklist of potential optimization spots that are usually hard to find in a Profiler when you don’t know what you are looking for.
1. Expensive Service Construction
There are a number of mistakes with services in the dependency injection container that can negatively impact performance. Your performance baseline with Symfony is affected by how expensive instantiating event listeners and services is for the different events of the HttpKernel lifecycle: kernel.request
, kernel.controller
, kernel.view
and kernel.response
.
Symfony lazy loads all listeners and depending services inside the EventDispatcher. We can look at this method in a Timeline Trace and see how much time can be wasted in service construction. Here is an example from an application we monitor in Tideways:
In this case about 51ms in ContainerAwareEventDispatcher::lazyLoad()
is spent right before the kernel.request
event is executed, which only takes 10ms itself. Something is clearly wrong in this application bootstrap code.
You should avoid the following traps when creating services in Symfony:
- The rule “Don’t perform work in the Constructor” is critical for Symfony, everything you do in a service constructor can potentially slow down every request of your application.If you use Doctrine Repositories as service and inject this into listeners, you already have this problem: Creating a repository service will unserialize ClassMetadata from the cache or even parse it.
- Services with deep layers of dependencies take a longer time to create. In the Symfony core, Security component is an offender of this trap, often when combined with the FOSUserBundle and more complex Firewall setups.
- Avoid injecting services into listeners that are not needed. For example, if you inject templating service somewhere into an EventListener it will get loaded in every request, which in turn loads Twig and every Twig extension that you registered. If your request is returning a JSON Response for an Ajax request this overhead is completely unnecessary.
Two ways exist here to remedy the problem:
- The old-school way is to only inject the Container into EventListeners and retrieve services from it when you need them. The downside of this approach is EventListeners are more difficult to test and maintain. The upside is that you don’t need code generation.
- Lazy Services were introduced in Symfony 2.3 to avoid injecting the container. This feature uses code-generation to create a proxy service and inject that instead of the real one. Once accessed the real service is loaded from the container.
Both ways work, but are only fighting the symptom: dependency mess. Fixing this is much harder to achieve though and out of your hand if you don’t want to replace third-party bundles.
In very fast Symfony Requests (~20-50ms) the Container::get($id)
call is often the number one bottleneck. If you really need a very fast response and high throughput, you must work on optimizing service construction.
2. Slow Kernel Event Listeners
Now that we got through listener and service instantiation, the baseline Symfony performance can suffer from slow listeners that get executed in every request.
Avoid calling a database or external services in listeners, unless you absolutely need to and then restrict this calls to only those requests.
For example in Tideways we use the context pattern to pass state around through the application. The context object contains the organization and site you are currently looking at, the time resolution and more details. Knowing this listener is part of the critical path, we optimized the queries it executes.
See this timeline of a request (58ms) with the PageContextListener highlighted:
The context listener takes 24.2% of the total request time and over 50% of the kernel.request
events time. This is significant and it is important to monitor that this listener isn’t wasting time with unnecessary work.
It is a good idea to have a feeling for how long kernel.request
takes in your application, maybe even the time each individual listener needs, because this event is the slowest one in most Symfony applications.
A special case for slow event listeners is the Security component in combination with a Doctrine or Propel based User object. A large user object with lots of properties and associations can slow down your application, because Symfony reloads the user from the database in every request. You can fix this problem by separating the User object Symfony Security needs from the one you are using in your application.
Another thing to look out for in listeners when you are using internal sub-requests: If your listener should only run for the master request, it is important to check for the type and skip for sub-requests. Otherwise the listener will get executed over and over again. Every Kernel Event class allows to checking for the type:
public function onKernelRequest(GetResponseEvent $event) {
if (!$event->isMasterRequest()) {
return;
}
}
See the documentation for more information.
3. Excessive Usage of Internal Subrequests
Internal Subrequests are simulated requests inside your Symfony application, allowing to call and render multiple controllers in the same PHP request. This is great for decoupling applications with many different views/widgets on a single page.
But unless you are using some kind of caching, excessive use of internal sub-requests can increase your response times significantly. Especially when you misuse them to render all kinds of “partials”.
Every sub-request will go through the HttpKernel event lifecycle and depending on your Listeners this can add several milliseconds for every subrequest.
Definitely avoid rendering sub-requests in loops, for example to display items in a list:
{% for product in products %}
{{ render(controller('AppBundle:Product:showList', {'product': product})) }}
{% endfor %}
Individually each call to a subcontroller is not too slow, but once the number of calls exceeds 10 or 50 per page, their overhead becomes very real. Often controllers used in this way exhibit N+1 query problems, because they cannot share the result of queries efficiently.
Instead of using subcontrollers you can often use a Twig {% include %}
statement. It has much less overhead. If the subcontroller does some work that cannot be done in the view, you could think about moving it into a Twig extension.
If your sub-controller is indeed an independent widget, another solution is to use ESI caching. Depending on the cache-lifetime this allows you to have a lot more sub-requests, if the reverse proxy is caching everything for you.
For highly dynamic pages with many widgets on a page, you should investigate different architectures like the one described in Bastian Hoffmann’s slidedeck.
If you are not using ESI or HttpCache from Symfony, but want to cache sub-requests take a look at Alexander’s Twig Cache Extension.
4. Not Delaying Work to the Background
Try to delegate expensive work to the background, such as sending emails or processing uploaded files and images.
When you use PHP FastCGI Process Manager (FPM) the kernel.terminate
event is executed after the response is sent to the user. You can see how powerful this is by looking at a timeline of a Symfony Controller sending a mail with Swiftmailer during kernel.terminate
:
The request ends for the user after less than 100ms, but takes another second to actually send the e-mail in the background.
Using kernel.terminate
for too much work can be unreliable, a better alternative is using one of the many existing messages queues. I can recommend Beanstalkd as a simple queue for first time users. In the Symfony ecosystem Redis and RabbitMQ are two other choices that are very popular.
If you are not using PHP-FPM, you cannot rely on kernel.terminate
, because users still wait for the response to finish.
5. Increasing “Framework Overhead” with Tons of Libraries and Bundles
A Symfony application is never just the framework alone: The standard edition ships with Doctrine DBAL + ORM, Twig, Monolog and Swiftmailer. Many other libraries have excellent support for Symfony such as Guzzle, Buzz, Propel and Imagine. And there are obviously lots of third party bundles, like FOSUserBundle, FOSRestBundle and JMSSerializer. Each of them adds a tiny bit to the baseline performance or framework overhead.
Building a Symfony application makes you responsible for choices about the desired performance level and the libraries you select can move the needle of the baseline quite significantly.
For example: When you want a REST API to respond below 75-100ms for even complex requests, then you will have a hard time to achieve this when using all the heavyweights in your code like Symfony Forms, Doctrine ORM and JMS Serializer. But if you only need a handful of endpoints to be this fast, then using these libraries in all the other endpoints may be fine.
Similarly, if you need a very fast frontend (eCommerce for example) then you can run into trouble when you combine this into one Symfony app with an oversized backend, containing many bundles that add various listeners to all the kernel events.
To provider an anchor, I work on different projects at the moment that clock between 20ms and 100ms for the Symfony framework+bundle overhead.
Conclusion
Symfony by itself is pretty fast, if you can avoid slowing it down with inefficient services, listeners and libraries or misuse of subrequests for partials. This is because of the sound architecture of Symfony as a framework.
First, being able to hook into the compiled dependency injection container avoids lots of runtime overhead that other frameworks quickly gain when going beyond Hello World. As a developer you are in full control of how expensive bootstrapping Symfony is.
Second, its first-class support for ESI and Http Caching allows you to easily plug any reverse proxies in front of the application and benefit from their performance properties.