Symfony vs Laravel: A humble request (Part 1)

If you’re reading this article you’ve likely already heard of Symfony and Laravel.
Which likely also means you’ve been involved in or read a discussion about one being better than the other. Maybe you even know the common arguments for one side or the other — they’re not hard to find and I’m not going to repeat any here.
I’ve worked with Symfony professionally since 2013 and Laravel since 2014. I’ll hold my hand up now and say for the vast majority of use cases — I’d pick Symfony.
In these articles I’m going to implement the same (fairly trivial) functionality in both Symfony and Laravel. I will follow the documentation as much as possible and use the most commonly applied methods (attributes for Doctrine annotations over XML/YAML for example). In both cases I will only use libraries provided by the framework creators. symfony/*
in Symfony. illuminate/*
in Laravel.
I hope by the end of these articles the reasons for my choice of Symfony will be clear, but if nothing else, hopefully somebody learns something from my ramblings.
I welcome any constructive criticism, feedback, suggestions, whatever else. It’s impossible for one person to know it all. If I’m wrong about something, please let me know, I like learning new things.
The Humble Request
{
"name": "Fred",
"age": 42,
"email": "fred@flintstones.com",
"country": "GBR",
"marketing_opt_in": true
}
These posts will provide a tutorial of how to create an API endpoint that accepts the above request and persists it to a database. The C in CRUD. I will cover validation, testing, documentation etc.
Requirements and specification
{
name: string, required
age: int, required
email: string, required, valid email
country: string, optional, ISO_3166-1_alpha-3
marketing_opt_in: bool
}
- The endpoint will be
/api/v1/sign_up
and it will only accept aPOST
request - The endpoint will only attempt to parse a
JSON
POST
body Name
is required, must be at least 3 characters, no more than 255 characters, must be persisted with the first letter of each word capitalised, the rest of the word in lower caseAge
is required, must be greater than 0 and lower than or equal to 150Email
is required, the email address must be validCountry
is optional, if provided it must conform to ISO 3166–1 alpha 3Marketing opt in
is required.- A successful response will be indicated by
HTTP code 201 (Created)
- A successful response will return the persisted values with the same keys as the request
- The columns in the database will be
name
,age
,email
,country
,allow_marketing
- A validation error will be indicated by
HTTP code 422
- Any other errors will be indicated by
HTTP code 400
Request Handling — Symfony
A few years ago this first step was a bit more involved, you needed event listeners, a ParamConverter
and you had to wire up the validation yourself.
Thankfully in Symfony 6.3 some new attributes were released. These attributes make it really easy to map an incoming request to a DTO.
The specification is very clear on what the request contains so making a representation of it in our application makes sense.
<?php
// file: src/Request/Api/v1/CreateSignUpAction.php
declare(strict_types=1);
namespace App\Request\Api\v1;
final class CreateSignUpRequest
{
private string $name;
private int $age;
private string $email;
private ?string $country = null;
private bool $marketingOptIn;
public function __construct(string $name, int $age, string $email, bool $marketingOptIn)
{
$this->name = $name;
$this->age = $age;
$this->email = $email;
$this->marketingOptIn = $marketingOptIn;
}
public function getName(): string
{
return $this->name;
}
public function getAge(): int
{
return $this->age;
}
public function getEmail(): string
{
return $this->email;
}
public function getCountry(): ?string
{
return $this->country;
}
public function setCountry(?string $country): void
{
$this->country = $country;
}
public function isMarketingOptin(): bool
{
return $this->marketingOptIn;
}
}
All of the required parameters are constructor arguments (can’t have a valid request without them) and they do not have set methods.
Country has both getters and setters as it’s optional.
I’m sure at least one person reading this is wondering why the class isn’t readonly
and why I’m using (awful boilerplate!!!111!) getters and setters — it’s because of how the Symfony Serializer works (or at least how I use it), I’ll cover this in a lot more detail later.
Symfony is a set of reusable PHP components…
If you’re following along in a new Symfony project you’ll need the validator
and intl
libraries for the next step. Unless you start a symfony project as a webapp
, you start with the bare minimum and add what you need as you go, unlike Laravel where everything is included from the get go.
If you miss installing these libraries the error you get from Symfony will prompt you to install them.
$ composer require symfony/validator symfony/intl
The validation rules can be added into the DTO like so
<?php
// file: src/Request/Api/v1/CreateSignUpAction.php
declare(strict_types=1);
namespace App\Request\Api\v1;
use Symfony\Component\Validator\Constraints as Assert;
final class CreateSignUpRequest
{
#[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\Country(alpha3: true)]
private ?string $country = null;
#[Assert\NotBlank]
private bool $marketingOptIn;
For the strict email validation another library is required. Breaking the rules around packages with this one.
However it is explicitly referenced in the documentation.
strict
validates the address according to RFC 5322 using the egulias/email-validator library (which is already installed when using Symfony Mailer; otherwise, you must install it separately).
$ composer require egulias/email-validator
So we now have a single PHP class that describes the incoming request with typed properties & it has validation rules which Symfony will automatically call for us.
Onto the controller — another library is required here
$ composer require symfony/serializer-pack
<?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)
{
}
}
The first parameter to __invoke
makes use of one of the attributes I mentioned a bit earlier — #[MapRequestPayload]
This attribute will deserialize the incoming JSON request into our CreateSignUpRequest
DTO and in doing so will run the validation rules. If any of the rules fail Symfony will automatically return a 422 (Unprocessable Content) response code.
Make sure to add the format: ‘json’
section to the #[Route]
attribute — this will ensure any validation errors are formatted and returned as JSON.
As the image below shows — no ambiguity here. The code is self documenting — no need to ask if anyone has a postman project they can share or waste any time trying to decipher the OpenAPI documentation that was started with the best intentions (a few years ago..)

*If anyone has tried running the code I’ve shared so far you’ll get a validation error on the marketing field — I’ll cover the fix for this at the end as it links in with the serializer
Request Handling — Laravel
I could be wrong here but I don’t think there’s an easy way to implement the functionality above in Laravel.
I know it’s kind of possible — see spatie/laravel-data
But I don’t think it’s a documented process like it used to be in Symfony.
What Laravel does offer is Form Requests.
$ php artisan make:request CreateSignUpRequest
Laravel doesn’t have ISO-3166 validation built in and I don’t really want to write my own. Thankfully a quick search and someone else has already done the work for us. As with Symfony — goes against my initial rules but it’s only a small piece of the overall functionality.
$ composer require jekk0/laravel-iso3166-validation-rules
The form request with validation rules added.
<?php
// file: app/Http/Requests/CreateSignUpRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Jekk0\Laravel\Iso3166\Validation\Rules\Iso3166Alpha3;
final class CreateSignUpRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => 'required|string|min:3|max:255',
'age' => 'required|int|gt:0|lte:150',
'email' => 'required|string|email:strict,dns',
'country' => ['nullable', 'string', new Iso3166Alpha3()],
'marketing_opt_in' => 'required|bool',
];
}
}
Personally i’m not a fan of writing validation rules out as strings (or an array of strings as with country). Harder to read, no way to validate I haven’t made up a rule or typed something wrong without running the code and seeing if it breaks.
Onto the controller
<?php
// file: app/Http/Controllers/Api/v1/CreateSignUpAction.php
declare(strict_types=1);
namespace App\Http\Controllers\Api\v1;
use App\Http\Requests\CreateSignUpRequest;
use Illuminate\Routing\Controller;
final class CreateSignUpAction extends Controller
{
public function __invoke(CreateSignUpRequest $createSignUpRequest)
{
}
}

In Laravel the FormRequest
doesn’t have properties like the DTO did in Symfony.
In all likelihood I’m going to want to use
$createSignUpRequest->validated();
The return type of which is mixed
.
Assuming the validation passes I’m pretty sure it’s going to give me an array like the below
[
"name" => "Fred"
"age" => 42
"email" => "fred@flintstones.com"
"country" => "GBR"
"marketing_opt_in" => true
];
So my code to fetch data from the request is going to end up looking something like
$request = $createSignUpRequest->validated();
$name = $request['name'];
$age = $request['age'];
$email = $request['email'];
$country = $request['country'] ?? null; // need to remember country is optional
$marketing = $request['marketing_opt_in'];
Which would make it the second time I’ve had to use the exact strings (name
, age
, email
, country
, marketing_opt_in
) as used in the JSON payload. Firstly as array keys in the FormRequest
validation and again here (it definitely won’t be the last either….)
Outside of that — Laravel is more or less doing the same things as Symfony. It’s taking my request, validating it, returning HTTP 422 if there are any errors, passing my typehinted object to the controller if there aren’t.
Undoubtedly, the Laravel code was much much faster to write. I didn’t have to think about what values went in the constructor, what needed getters and setters and what didn’t etc.
However, I’m accessing keys on arrays with strings, I have to look in the FormRequest
to see what the strings are and what types they should be, I have to code defensively for the optional values. There’s no IDE autocompletion to help me.
At least that’s true once Laravel got out of my way. You’ve gotta install the API routes, which automatically wraps them all in Sanctum Auth
php artisan install:api
// file: bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
apiPrefix: 'api/v1',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
// file: routes/api.php
Route::post('sign-up', CreateSignUpAction::class)->withoutMiddleware(['auth:sanctum']);
Then for some reason I only ever got a 302 redirect to the homepage, the logs had nothing in them at all, a bit of Googling led me to adding this header.
Accept: application/json
I’ll put these minor annoyances down to the fact I prefer Symfony’s approach of — here’s the bare minimum, add what you need vs Laravel’s let me do all the things for you & it’s been a while since I’ve created a Laravel project from scratch.
Summary
Laravel wins hands down on speed to delivery.
Symfony required a bit more thinking but for me delivers the better developer experience going forward. I’ve got a fully typed object that represents my request vs an untyped array.
Addendum — Symfony Serializer
I said earlier I’d write a bit more about my choice of DTO and how it relates to the Serializer.
Symfony’s Serializer is a really powerful library, it’s also pretty complex.
The Symfony docs make it look easy to use, just typehint SerializerInterface
and off you go
public function index(SerializerInterface $serializer): Response
{
$person = new Person('Jane Doe', 39, false);
$jsonContent = $serializer->serialize($person, 'json');
// $jsonContent contains {"name":"Jane Doe","age":39,"sportsperson":false}
return JsonResponse::fromJsonString($jsonContent);
}
Behind the scenes there’s actually quite a bit going on.
$ php bin/console debug:container --tag serializer.encoder.default
Symfony Container Services Tagged with "serializer.encoder.default" Tag
=======================================================================
------------------------- --------------------------------------------------
Service ID Class name
------------------------- --------------------------------------------------
serializer.encoder.xml Symfony\Component\Serializer\Encoder\XmlEncoder
serializer.encoder.json Symfony\Component\Serializer\Encoder\JsonEncoder
serializer.encoder.yaml Symfony\Component\Serializer\Encoder\YamlEncoder
serializer.encoder.csv Symfony\Component\Serializer\Encoder\CsvEncoder
------------------------- --------------------------------------------------
$ php bin/console debug:container --tag serializer.normalizer.default
Symfony Container Services Tagged with "serializer.normalizer.default" Tag
==========================================================================
------------------------------------------------- ---------- ---------------------------------------------------------------------------
Service ID priority Class name
------------------------------------------------- ---------- ---------------------------------------------------------------------------
serializer.denormalizer.unwrapping 1000 Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer
serializer.normalizer.problem -890 Symfony\Component\Serializer\Normalizer\ProblemNormalizer
serializer.normalizer.uid -890 Symfony\Component\Serializer\Normalizer\UidNormalizer
serializer.normalizer.datetime -910 Symfony\Component\Serializer\Normalizer\DateTimeNormalizer
serializer.normalizer.constraint_violation_list -915 Symfony\Component\Serializer\Normalizer\ConstraintViolationListNormalizer
serializer.normalizer.datetimezone -915 Symfony\Component\Serializer\Normalizer\DateTimeZoneNormalizer
serializer.normalizer.dateinterval -915 Symfony\Component\Serializer\Normalizer\DateIntervalNormalizer
serializer.normalizer.form_error -915 Symfony\Component\Serializer\Normalizer\FormErrorNormalizer
serializer.normalizer.backed_enum -915 Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer
serializer.normalizer.data_uri -920 Symfony\Component\Serializer\Normalizer\DataUriNormalizer
serializer.normalizer.json_serializable -950 Symfony\Component\Serializer\Normalizer\JsonSerializableNormalizer
serializer.denormalizer.array -990 Symfony\Component\Serializer\Normalizer\ArrayDenormalizer
serializer.normalizer.object -1000 Symfony\Component\Serializer\Normalizer\ObjectNormalizer
------------------------------------------------- ---------- ---------------------------------------------------------------------------
When you typehint SerializerInterface
you’re getting something like this
$normalizers = [
new Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer()
new Symfony\Component\Serializer\Normalizer\ProblemNormalizer()
new Symfony\Component\Serializer\Normalizer\UidNormalizer()
new Symfony\Component\Serializer\Normalizer\DateTimeNormalizer()
new Symfony\Component\Serializer\Normalizer\ConstraintViolationListNormalizer()
new Symfony\Component\Serializer\Normalizer\DateTimeZoneNormalizer()
new Symfony\Component\Serializer\Normalizer\DateIntervalNormalizer()
new Symfony\Component\Serializer\Normalizer\FormErrorNormalizer()
new Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer()
new Symfony\Component\Serializer\Normalizer\DataUriNormalizer()
new Symfony\Component\Serializer\Normalizer\JsonSerializableNormalizer()
new Symfony\Component\Serializer\Normalizer\ArrayDenormalizer()
new Symfony\Component\Serializer\Normalizer\ObjectNormalizer()
];
$encoders = [
new Symfony\Component\Serializer\Encoder\XmlEncoder()
new Symfony\Component\Serializer\Encoder\JsonEncoder()
new Symfony\Component\Serializer\Encoder\YamlEncoder()
new Symfony\Component\Serializer\Encoder\CsvEncoder()
];
return new Serializer($normalizers, $encoders);
You can read a bit more about all the normalizers here
As default Symfony uses the ObjectNormalizer
This is the most powerful default normalizer and used for any object that could not be normalized by the other normalizers.
It leverages the PropertyAccess Component to read and write in the object. This allows it to access properties directly or using getters, setters, hassers, issers, canners, adders and removers. Names are generated by removing theget
,set
,has
,is
,add
orremove
prefix from the method name and transforming the first letter to lowercase (e.g.getFirstName()
->firstName
).
During denormalization, it supports using the constructor as well as the discovered methods.
I typically switch this out and use the GetSetMethodNormalizer
This normalizer is an alternative to the defaultObjectNormalizer
. It reads the content of the class by calling the "getters" (public methods starting withget
,has
,is
orcan
). It will denormalize data by calling the constructor and the "setters" (public methods starting withset
).
Objects are normalized to a map of property names and values (names are generated by removing theget
prefix from the method name and transforming the first letter to lowercase; e.g.getFirstName()
->firstName
).
If I wanted to have a readonly
DTO class with optional properties (so some properties are not set on __construct
) — then I would need the PropertyNormalizer
This is yet another alternative to the ObjectNormalizer
. This normalizer directly reads and writes public properties as well as private and protected properties (from both the class and all of its parent classes) by using PHP reflection. It supports calling the constructor during the denormalization process.
Objects are normalized to a map of property names to property values.
You can also limit the normalizer to only use properties with a specific visibility (e.g. only public properties) using thePropertyNormalizer::NORMALIZE_VISIBILITY
context option. You can set it to any combination of thePropertyNormalizer::NORMALIZE_PUBLIC
,PropertyNormalizer::NORMALIZE_PROTECTED
andPropertyNormalizer::NORMALIZE_PRIVATE
constants:
Because once a class is constructed. It’s readonly
, so to set the optional properties with the Serializer it needs to use reflection.
I tend to use the GetSetMethodNormalizer
because it can only do the things I can (without using reflection) —which is to call the getters and setters. If there’s no setter, I can’t set a value, a developer in 3 years time who picks up my code can’t and neither can the serializer.
If I don’t want to expose a value, I don’t create a getter (or create a different representation of the object without the property defined)
It makes everything easier to reason about and avoids the nightmare of having serializer groups everywhere.
It should also be more performant, calling getters and setters is going to be faster than using reflection to alter properties.
I know a lot of people just see “boilerplate code” but it actually serves a really useful purpose which I will hopefully show in later posts.
And finally the marketing_opt_in
vs marketingOptIn
validation error.
It’s fairly common for API’s to work with snake_cased_values
where as PHP with the PSR standards tend to use camelCasedValues
.
Symfony provides a NameConverter
for this specific use case —just enable it and clear the cache.
// file: config/packages/serializer.php
use Symfony\Config\FrameworkConfig;
return static function (FrameworkConfig $framework): void {
$framework->serializer()
->nameConverter('serializer.name_converter.camel_case_to_snake_case')
;
};
$ php bin/console cache:clear
Comments ()