Container Efficiency in Modular Monoliths: Symfony vs. Laravel
In the evolving landscape of software development, modular monolith architectures have gained significant traction. This approach offers a balanced middle ground between traditional monolithic applications and microservices. However, choosing the right PHP framework for building modular monoliths is crucial, as it can have a profound impact on the application’s performance. The container performance, which is critical for dependency injection and managing service lifecycles, varies significantly between frameworks such as Symfony and Laravel. Despite the rise of framework-agnostic practices, the reality is that switching frameworks can be prohibitively expensive. It’s crucial to make an informed decision when choosing a framework. This article explores a comparative analysis of container performance in Symfony and Laravel within modular monolith architectures, offering engineers valuable insights to guide their framework selection.
Motivation for this article
A poor performance of Laravel’s container isn’t a new thing. I’ve written about it an article: Uncovering the bottlenecks: An investigation into the poor performance of Laravel’s container
I had a high motivation to create some pull requests to Laravel to improve this performance.
The first and simplest one: New option – Shared dependencies by default
It was closed after a few minutes with some generic message, and I even started some discussion on X.
https://x.com/Sarvendev/status/1783773524944961741
After a long discussion, I got some information, that maintainers are afraid of how this change could impact the whole Laravel ecosystem.
Then I found some small performance issue in the bootstrapping process, which I described here: Laravel: Bootstrap time optimization by using a hashtable to store providers
And it was merged. So it gave me hope that maybe with some smaller changes, it will be possible to fix these performance issues in the container. So I created another pull request with caching reflection data: https://github.com/laravel/framework/pull/51794
But it was closed:
So for now, there is no option to improve the performance of the container due to the following:
- every longer change to the core will be probably declined
- the container in Laravel is highly coupled with the rest of the framework and there is no possibility without any hacky solution to use some different container
Laravel vs Symfony
To show what can be improved in Laravel it would be best to compare it to the main competitor – Symfony. I’ve created a simple repository: https://github.com/sarven/laravel-optimization-test/tree/laravel-vs-symfony
I’ve added a few services but shaped them as a complicated dependency tree. Of course, it’s quite exaggerated, but in large monolithic applications is possible to have such a complex dependency tree.
And I’ve written a test like this to compare these two frameworks:
<?php
declare(strict_types=1);
use App\Services\RootService;
use App\Services\RootService2;
use Illuminate\Contracts\Http\Kernel;
require_once __DIR__ . '/laravel/vendor/autoload.php';
$count = 200;
$data = [];
for ($i = 0; $i < $count; $i++) {
$app = require __DIR__ . '/laravel/bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
$start = microtime(true);
$startMemory = memory_get_usage(true);
$service = $app->make(RootService::class);
$service = $app->make(RootService::class);
$service = $app->make(RootService2::class);
$service = $app->make(RootService2::class);
$end = microtime(true);
$endMemory = memory_get_usage(true);
$data[] = [
'time' => ($end - $start) * 1000,
'memory' => $endMemory - $startMemory,
];
}
$averageTime = array_sum(array_column($data, 'time')) / $count;
$averageMemory = array_sum(array_column($data, 'memory')) / $count;
dump($averageTime . ' ms');
dump(($averageMemory / 1024) . ' KB');
<?php
declare(strict_types=1);
use App\Kernel;
use App\Services\RootService;
use App\Services\RootService2;
require_once __DIR__ . '/symfony/vendor/autoload.php';
$count = 200;
$data = [];
for ($i = 0; $i < $count; $i++) {
$kernel = new Kernel('dev', false);
$kernel->boot();
$container = $kernel->getContainer();
$start = microtime(true);
$startMemory = memory_get_usage(true);
$service = $container->get(RootService::class);
$service = $container->get(RootService::class);
$service = $container->get(RootService2::class);
$service = $container->get(RootService2::class);
$end = microtime(true);
$endMemory = memory_get_usage(true);
$data[] = [
'time' => ($end - $start) * 1000,
'memory' => $endMemory - $startMemory,
];
}
$averageTime = array_sum(array_column($data, 'time')) / $count;
$averageMemory = array_sum(array_column($data, 'memory')) / $count;
dump($averageTime . ' ms');
dump(($averageMemory / 1024) . ' KB');
This test creates a new Kernel / Application and creates four instances of these two services. To make this more predictable, the test is repeated 200 times, and only the average values are presented.
Results:
Laravel
"19.366332292557 ms" // test.php:38
"20.48 KB" // test.php:39
Symfony
"0.017472505569458 ms"
"10.24 KB"
Laravel resolves all services at runtime, and there is no such thing in this case as a shared instance. So the container needs to create many instances of those objects. More about shared instances you can read here: https://sarvendev.com/2023/04/uncovering-the-bottlenecks-an-investigation-into-the-poor-performance-of-laravels-container/
So every time a service is needed, the container has to parse reflection data, create dependencies, create dependencies of dependencies, and so on. The more complicated the dependency tree, the more processing time it takes. Here is the sample screenshot from the profiler (Blackfire) which shows how it looks under the hood (it’s from the real application, not the test one):
On the other hand, Symfony has a precompiled configuration, which speeds up resolving dependencies. All instances are shared by default.
In the service container, all services are shared by default. This means that each time you retrieve the service, you’ll get the same instance.
https://symfony.com/doc/current/service_container/shared.html
The container is compiled before going to the production, and it looks like this:
class getRootServiceService extends App_KernelDevDebugContainer
{
/**
* Gets the public 'App\Services\RootService' shared autowired service.
*
* @return \App\Services\RootService
*/
public static function do($container, $lazyLoad = true)
{
include_once \dirname(__DIR__, 4).'/src/Services/RootService.php';
return $container->services['App\\Services\\RootService'] = new \App\Services\RootService(
($container->privates['App\\Services\\GlobalService'] ?? $container->load('getGlobalServiceService')),
($container->privates['App\\Services\\GlobalService1'] ?? $container->load('getGlobalService1Service')),
($container->privates['App\\Services\\GlobalService2'] ?? $container->load('getGlobalService2Service')),
($container->privates['App\\Services\\GlobalService3'] ?? $container->load('getGlobalService3Service')),
($container->privates['App\\Services\\GlobalService4'] ?? $container->load('getGlobalService4Service')),
($container->privates['App\\Services\\GlobalService5'] ?? $container->load('getGlobalService5Service'))
);
}
}
Laravel possible improvement
Laravel could be improved in a few smaller steps:
- caching reflection data – it should speed up a little resolving the same dependency many times
- cached reflection data generated during the deployment of the application – it could be saved to the file as other cache files: config, routes, etc.
- precompilation of the full container during the deployment – similar to the Symfony approach
- possibility to define auto-wired services as shared by default – now, it is possible for services that need binding (scoped, singleton), but currently everything without an interface doesn’t need to be declared in providers and there is no possibility to define those services as shared (exactly there is a possibility, but then every service needs to be defined in providers, Read more here: https://sarvendev.com/2023/04/uncovering-the-bottlenecks-an-investigation-into-the-poor-performance-of-laravels-container/ – Auto-wired services)
Summary
Many factors impact our decision in choosing the right framework for our application. This time, I compared the performance of containers. Bearing in mind the performance of these two container implementations, I think that Symfony is a much better choice for a modular monolith architecture. For smaller applications, the difference won’t be as noticeable as it is for larger ones, but if you strive for better performance, you should thoroughly consider your decision.
Subscribe and master unit testing with my FREE eBook (+60 pages)! 🚀
In these times, the benefits of writing unit tests are huge. I think that most of the recently started projects contain unit tests. In enterprise applications with a lot of business logic, unit tests are the most important tests, because they are fast and can us instantly assure that our implementation is correct. However, I often see a problem with good tests in projects, though these tests’ benefits are only huge when you have good unit tests. So in this ebook, I share many tips on what to do to write good unit tests.
Thanks for the effort of comparing the performance. I am shocked how much difference is there 😳
You can check this container library from Infocyph
https://github.com/infocyph/InterMix
I can’t because the container in Laravel is highly coupled to the core. The main Application class extends Container instead of using a composition. It’s possible to prepare some hacky solution, but maintenance would be a nightmare.
This really a good post and a lot more good analysis: thank you!
I’ll go deeper in the code to see the numbers with my eyes, but you did really a very good starting point: thank you!
Hi, thanks for your lovely article here.
I have started a blog myself and one of the articles I wrote was why some frameworks are more suitable for ‘enterprise’ applications (see the website field I entered in my reply).
I took the service container of Symfony and Laravel as an example of “Don’t repeat execution” and this benchmark shows this very well. A factor 1108 is really ridiculous, so I have made a reference to your article from my article.
Did you benchmark with or without opcache enabled? If you did without, it might be even more than 1108 in a real application if opcache is enabled.
Of course in a real application there are more factors for the overall speed. For example Eloquent hydrates faster than Doctrine, because Eloquent objects is just an array wrapper/query builder magic object.
Hi, it was tested by CLI test, so there won’t be a big difference between opcache and no opcache. If you want to explore more, feel free to use that example repository from the article to create endpoints and you can use ab tool to conduct some tests. I was doing that in some other benchmarks, but not included in this article.
In the case of ORMs, in Doctrine in enterprise applications, it’s rather recommended to use separate read models and fetch data by DBAL. It would be much faster than hydration by reflection.
BTW there is another fun fact in Laravel, there is no support for opcache preload feature 😀 In Symfony this feature is provided out of the box.