Symfony vs Laravel: A humble request (Part 2)

I had planned to talk about documentation and testing with this post but thanks to hindsight and the feedback from r/PHP (which I really appreciate 🙏) I'm going to take a slight detour.
In the previous post I talked about the Request part of the Request-Response lifecycle in Symfony in a single sentence, then went into a lot of detail about Symfony’s Serializer. Helpful if you already know all about both those things, kinda useless if you don't.
So what is the request-response lifecycle? And how are the two frameworks different?
Request-Response Lifecycle
- The user asks for a resource in a browser;
- The browser sends a request to the server;
- Symfony/Laravel give the application a Request object;
- The application generates a Response object using the data of the Request object;
- The server sends back the response to the browser;
- The browser displays the resource to the user.
To get a better understanding of this I'm going to take a big step back in time....

...and grab this book from my bookshelf.
I’m going to hazard a guess that a decent portion of people reading this post won’t have been writing PHP for a living back when books taught you to write code like this

Hopefully reading that makes you shudder as much as it does me.
Before the days of composer packages and proper OO - the directory tree of a simple PHP site might look a bit like this.
├── blog.php
├── contact-us.php
├── includes
│ └── mysql.php
├── index.php
└── register.php
Any of the user input you worked with would come directly from PHP's superglobals.
As you can see in the image above - it's handling the submission of a form and grabbing the values from the $_POST
superglobal - which is just an array.
Now you might be able to guess why SQL injection was such a big thing back then.
SELECT *
FROM users
WHERE id = {$_GET['id']};
Never trust a users raw input...
Thankfully as PHP matured - we got packagist and composer.
If you have a look through Packagist's most popular packages you'll find it's mostly Symfony, PSR interfaces and Guzzle.
Among that list is symfony/http-foundation
The HttpFoundation component defines an object-oriented layer for the HTTP specification.
In PHP, the request is represented by some global variables ($_GET
,$_POST
,$_FILES
,$_COOKIE
,$_SESSION
, ...) and the response is generated by some functions (echo
,header()
,setcookie()
, ...).
The Symfony HttpFoundation component replaces these default PHP global variables and functions by an object-oriented layer.
If you have a look at which packages are dependant on symfony/http-foundation
- you'll find laravel/framework
close to the top of the list.
In both frameworks it's where the request-response lifecycle begins (number 3 on the list)
// file: vendor/symfony/runtime/SymfonyRuntime.php
public function getRunner(?object $application): RunnerInterface
{
if ($application instanceof HttpKernelInterface) {
return new HttpKernelRunner($application, Request::createFromGlobals(), $this->options['debug'] ?? false);
}
...
}
// file: src/Illuminate/Http/Request.php
/**
* Create a new Illuminate HTTP request from server variables.
*
* @return static
*/
public static function capture()
{
static::enableHttpMethodParameterOverride();
return static::createFromBase(SymfonyRequest::createFromGlobals());
}
Laravel's Request
extends Symfony's. It mostly adds a bunch of helper/shorter named methods but also some extra functionality for things like getting the logged in user directly from the request.
<?php
namespace Illuminate\Http;
class Request extends SymfonyRequest implements Arrayable, ArrayAccess
{
....
/**
* Get the host name.
*
* @return string
*/
public function host()
{
return $this->getHost();
}
/**
* Get the HTTP host being requested.
*
* @return string
*/
public function httpHost()
{
return $this->getHttpHost();
}
/**
* Get the scheme and HTTP host.
*
* @return string
*/
public function schemeAndHttpHost()
{
return $this->getSchemeAndHttpHost();
}
/**
* Determine if the request is the result of an AJAX call.
*
* @return bool
*/
public function ajax()
{
return $this->isXmlHttpRequest();
}
....
}
So what are the superglobals?
$request = self::createRequestFromFactory(
$_GET, // An associative array of query string variables
$_POST, // An associative array of variables passed by either application/x-www-form-urlencoded or multipart/form-data
[],
$_COOKIE, // An associative array of variables passed to the current script via HTTP Cookies.
$_FILES, // An associative array of items uploaded to the current script via the HTTP POST method
$_SERVER // An array containing information such as headers, paths, and script locations
);
Thanks to this library, neither Laravel or Symfony has to deal directly with the arrays of data from the superglobals. Nor do we have to manually extract headers from the $_SERVER
superglobal.
/**
* Request body parameters ($_POST).
*
* @see getPayload() for portability between content types
*/
public InputBag $request;
/**
* Query string parameters ($_GET).
*/
public InputBag $query;
/**
* Server and execution environment parameters ($_SERVER).
*/
public ServerBag $server;
/**
* Uploaded files ($_FILES).
*/
public FileBag $files;
/**
* Cookies ($_COOKIE).
*/
public InputBag $cookies;
/**
* Headers (taken from the $_SERVER).
*/
public HeaderBag $headers;
It's the InputBag
that gives both Symfony and Laravel some of the most often used request methods.
$request->get();
$request->has();
$request->all();
Much more useful than dealing directly with an array.
Both frameworks then follow the same pattern of having a Kernel
that handles the request and returns a response.
It looks something a bit like this
$kernel = new Kernel();
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
Symfony's Kernel
sets up configuration paths, logging paths and creates the DI container.
Laravel's Kernel
mostly just sets up middleware. The configuration of paths and creating the container is left to the Application
. You'll likely be familiar with it through $this->app->make()
etc..
So why doesn't Symfony have middleware? And why can I easily transform my request into a DTO in Symfony but not in Laravel?
Kernel Events
In Symfony the $kernel->handle()
method works by dispatching events. Most of the heavy lifting is carried out by event listeners/subscribers.
There are 8 kernel events within Symfony. I'm not going to go over them all - the documentation does a really good job of it.
The first of the kernel events to be triggered is kernel.request
$ php bin/console debug:event-dispatcher kernel.request
Registered Listeners for "kernel.request" Event
===============================================
------- --------------------------------------------------------------------------------------- ----------
Order Callable Priority
------- --------------------------------------------------------------------------------------- ----------
#1 Symfony\Component\HttpKernel\EventListener\DebugHandlersListener::configure() 2048
#2 Symfony\Component\HttpKernel\EventListener\ValidateRequestListener::onKernelRequest() 256
#3 Symfony\Bridge\Doctrine\Middleware\IdleConnection\Listener::onKernelRequest() 192
#4 Symfony\Component\HttpKernel\EventListener\SessionListener::onKernelRequest() 128
#5 Symfony\Component\HttpKernel\EventListener\LocaleListener::setDefaultLocale() 100
#6 Symfony\Component\HttpKernel\EventListener\RouterListener::onKernelRequest() 32
#7 Symfony\Component\HttpKernel\EventListener\LocaleListener::onKernelRequest() 16
#8 Symfony\Component\HttpKernel\EventListener\LocaleAwareListener::onKernelRequest() 15
------- --------------------------------------------------------------------------------------- ----------
Laravel does a lot of the same things as Symfony does, it has to match the URL to a route, which in turn matches to a controller to run your code. It has to set up a session. Those processes you cannot hook into or influence in any way, not without changing the code of the core Laravel files (at least I think that's the case...)
In Symfony on the other hand - because everything is done by events and event listeners, you can also listen to these events and execute code. And unlike Laravel where the order of execution of middleware is determined by the ordering of items in an array, in Symfony the order is handled by the priority
property. The higher the number the earlier the listener runs.
The third Kernel event is kernel.controller_arguments
, at this point Symfony knows which controller is to be called. It uses reflection to get the arguments that the controller needs to function and then uses Resolvers
to provide them.
In the controller guide, you've learned that you can get the Request object via an argument in your controller. This argument has to be type-hinted by the Request
class in order to be recognized. This is done via the ArgumentResolver. By creating and registering custom value resolvers, you can extend this functionality.
<?php
// file: src/Controller/Api/v1/CreateSignUpAction.php
declare(strict_types=1);
namespace App\Controller\Api\v1;
use App\Request\Api\v1\CreateSignUpRequest;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
#[AsController]
final readonly class CreateSignUpAction
{
#[Route('/api/v1/sign-up', methods: ['POST'], format: 'json')]
public function __invoke(#[MapRequestPayload] CreateSignUpRequest $createSignUpRequest)
{
}
}
So it's at this point Symfony knows it has to call CreateSignUpAction::__invoke()
and in order to do so, it needs to resolve [MapRequestPayload] CreateSignUpRequest $createSignUpRequest
to a value, more specifically an instance of CreateSignUpRequest
It does this with RequestPayloadValueResolver
Symfony has a quite a few value resolvers built into the framework and as these run on an event, which you can also listen to, you can create your own.
But back to a very trimmed down RequestPayloadValueResolver
- just so I can highlight a few parts.
<?php
namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* @author Konstantin Myakshin <molodchick@gmail.com>
*
* @final
*/
class RequestPayloadValueResolver implements ValueResolverInterface, EventSubscriberInterface
{
public function onKernelControllerArguments(ControllerArgumentsEvent $event): void
{
$arguments = $event->getArguments();
foreach ($arguments as $i => $argument) {
if ($argument instanceof MapRequestPayload) {
$payloadMapper = $this->mapRequestPayload(...);
}
$request = $event->getRequest();
$payload = $payloadMapper($request, $argument->metadata, $argument);
// ...
$this->validator->validate($payload, $constraints, $argument->validationGroups ?? null);
// ...
}
$event->setArguments($arguments);
}
}
To distill down (and simplify) what's happening in this resolver
- Grab everything from the request
- Use the serializer to convert the request into the object we type-hinted -
CreateSignUpRequest
- Pass
CreateSignUpRequest
to the validator for validation - If it passes validation, hand it over to be passed to
CreateSignUpAction::__invoke()
What makes this process interesting is - it's something you can do yourself quite easily. Take some data, use the serializer to create an object from it and then validate it.
CreateSignUpRequest
encapsulates all our validation rules, it's only dependency is on the validator, other than that it's just a POPO.
I wouldn't recommend the below on a big data set - there will be more performant ways.
$formatter = static fn (array $row): array => array_map(trim(...), $row);
$csv = Reader::createFromPath(__DIR__ . '/import.csv', 'r')
->addFormatter($formatter)
->setHeaderOffset(0);
foreach ($csv->getRecords() as $record) {
/** @var CreateSignUpRequest $data **/
$data = $this->serializer->denormalize($record, CreateSignUpRequest::class);
$errors = $this->validator->validate($data);
// ...
}
Or maybe you have a way to replicate what the API endpoint we're building will eventually do - but in a console command.
class SomeCommand extends Command
{
// ...
protected function execute(InputInterface $input, OutputInterface $output): int
{
$request = new CreateSignUpRequest(
$this->getArgument('name'),
$this->getArgument('age'),
$this->getArgument('email'),
false
);
// ...
}
}
But what about middleware?
As you've probably gathered by now - although Symfony doesn't have middleware in the typical sense. It has something a lot more powerful.
You can implement the "typical" idea of middleware - before and after hooks. By using the kernel.controller
event for a before hook and the kernel.response
event for an after hook.
The important thing to note if you want to compare to Laravel, these events are fired every single request. There's no way to register them to a group of routes (and then cause a hard to find bug by excluding it on another route within that group). So you'll want to find a way to return early if you don't need whatever is in your "middleware" to run.
Creating an event listener in Symfony is as simple as creating a class with an __invoke
method and registering it using #[AsEventListener()]
An example could be v2 of your API, you could be developing it but have it switched off via a feature flag.
<?php
declare(strict_types=1);
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
#[AsEventListener(event: KernelEvents::CONTROLLER, priority: 100)]
final readonly class ApiV2FeatureFlagListener
{
public function __construct(private FeatureFlagService $featureFlagService)
{
}
public function __invoke(ControllerEvent $event): void
{
$request = $event->getRequest();
/** This contains the FQN of the controller that will be called */
$controllerClass = $request->attributes->get('_controller');
/** return early as this listener is called on every request */
if (!str_starts_with('App\Controller\Api\v2', $controllerClass)) {
return;
}
if (!$this->featureFlagService->isEnabled(FeatureFlag::API_V2)) {
throw new AccessDeniedHttpException();
// Alternatively you could redirect the user back to v1 of the API with
// $event->setController();
}
}
}
Depending on your application and how you handle feature flags, the above might not the be the best way to handle this - it's just an example.
Remeber to set the priority
for when you want your listener to run. You can check what else is registered using
$ php bin/console debug:event-dispatcher

Also keep in mind that each of the kernel.*
events will recieve a different Event
to the __invoke
method and that you need to choose the correct event for your functionality. If you need the currently logged in user for example, you won't get that during kernel.request
because it's too early in the lifecycle.
The documentation has a great diagram showing at which point during the lifecycle which event is triggered.
Laravel
I'd love to go into as much detail here with how the lifecycle works in Laravel. However the documentation is no where near as detailed. Laravel doesn't really offer any way to interact with or alter the lifecycle other than via middleware, even then the documentation just explains what middleware is and how you would implement your own - not how it's built under the hood. That's not to say thats a problem, it isn't, you don't need to understand how it works to use it. It just doesn't offer the same extensibility as Symfony.
For me it does point towards one of the major differences between the two frameworks.
Symfony is a set of tools, the FrameworkBundle provides a fairly transparent and extensible mechanism of using these tools together to create an application. The documentation explains how these tools work and how the FrameworkBundle utilises them. It then provides a couple of examples and best practices for how you can use these tools yourself. The ceiling to what you can create with these tools is usually either - your own ability or the limitations of PHP itself. Handling large CSV's for example, you'd probably be better off using something like pandas, although a big shout-out to FlowPHP here.
I'll likely cover some use cases of FlowPHP in future articles
Laravel takes a different approach, the documentation is full of examples, chances are you can find an example showing how to solve the problem you need to solve a vast majority of the time. It's very light on how any of the functionality it shows you in the examples actually work under the hood. It's also very unopinionated on which of all the many examples it shows you is the better way to do things and why.
Let's take the container and resolving arguments
$this->app->make(Transistor::class);
$this->app->makeWith(Transistor::class, ['id' => 1]);
App::make(Transistor::class);
app(Transistor::class);
public function __construct(
protected Transistor $transistor,
) {}
public function generate(Transistor $transistor): array
{
return [
// ...
];
}
An analogy i've used before a few times: If Symfony is a set of tools, the documentation teaches you how to use them and how to build a table. From there you have the knowledge and the tools you need to make a chair, a wardrobe, whatever you want.
Laravel is massive IKEA warehouse, they've got almost every different type and colour of chair and table you can imagine. All you need is a screwdriver to put them together. They won't stop you putting metal legs on the wooden chair that aren't really meant to fit and if you want something they don't have - you're a bit stuck.
My aim here isn't to say one framework is better than the other. I said at the very beginning I prefer Symfony - I've been doing this job long enough I'd rather build my own furniture than put together a SKOGSTA but that's my choice.
There's also nothing wrong with IKEA furniture, I have quite a bit in my house. It's quick and easy to put together (just like Laravel) but I know it's not built as well as my handmade furniture and I know it won't last as long. That was a trade-off I knew I was taking at the time.
To end this article I want to answer a few questions/comments I had on r/PHP about my previous article.
Why don’t you use constructor property promotion in your DTO?
For a few reasons.
I prefer only passing required properties via the constructor - you'll see why shortly. Which means I still have to set properties at the top of the class any way.
With the annotations for validation and later documentation, it's much easier to read than this and that's only 4 properties - imagine 10 or 15.
final class CreateSignUpRequest
{
#[Assert\Country(alpha3: true)]
private ?string $country = null;
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(min: 3, max: 255)]
private string $name,
#[Assert\NotBlank]
#[Assert\GreaterThan(0)]
#[Assert\LessThanOrEqual(150)]
private int $age,
#[Assert\NotBlank]
#[Assert\Email(mode: 'strict')]
private string $email,
#[Assert\NotBlank]
private bool $marketingOptIn,
) {}
}
Laravel Data provides literally all of the functionality you are talking about here? Attributes, Casters, Resolvers, Validations...plus some other cool stuff.
It does - and if you're building a Laravel application. I'd recommend using it.
However it's not part of Laravel, so it's not really a fair comparison.
From the consumer's POV your DTO is not an object with typed fields. It's an object with a couple of getters. As pointed out by multiple commenters, you can achieve the same in Laravel by defining the same getters with the exact same code.
class CreateSignUpRequest extends FormRequest
{
public function getName(): string
{
return $this->name;
}
public function getAge(): int
{
return $this->age;
}
}
I can see where these comments are coming from. However I've shown in this article populating my DTO from a CSV and then validating it & using it to accept arguments from a console command.
As part of these articles we're building an API endpoint, which consumers will need to interact with. Larger projects/libraries tend to offer SDK's, or someone will make something in a specific language and put it on Github.
<?php
final class SignUpClient
{
public function create(CreateSignUpRequest $request)
{
$result = $this->client->post('api/v1/sign-up', [
'json' => $this->serializer->normalize($request)
]);
// ...
}
}
// ...
$request = new CreateSignUpRequest(); // my IDE is going to tell me which parameters are required - they're all in the constructor
$request->setXX(); // I've got setters for any optional parameters
$this->signUpClient->create($request);
The DTO's can be used to create an API client library - might be useful on Github for people to easily interact with your API or for use with some of your other services internally. The Request
objects themselves are self documenting which makes interaction much simpler than having to grok every bit of the documentation.
Laravel FormRequest
's extend the base Laravel Request
.
The FormRequest
itself has a lot of dependencies.
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\Access\Response;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Validation\Factory as ValidationFactory;
use Illuminate\Contracts\Validation\ValidatesWhenResolved;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Validation\ValidatesWhenResolvedTrait;
You're not making use of that anywhere that's not a HTTP request and inside Laravel any time soon.
The two objects and the functionality they offer - are totally different things.
Comments ()