Symfony vs Laravel - A humble request (Part 3)

In this post I'll actually make some progress towards persisting our humble request. Before that I'm going to talk about a snake, to be more precise - Python.
Very early in my career I was really fortunate to work alongside a really experienced and patient developer. Without a doubt he shaped the start of my career and I probably wouldn't be where I am now without him. It's probably part of the reason I'm writing these posts - an attempt to share what I've learnt in an attempt to help others on their own path.
When I first joined the company where I worked with this developer, I'd been turning PSD's into Wordpress templates and done a little bit of PHP for maybe 18 months. It was a big step up for me to be writing code using CodeIgniter and before too long Python for globally known brands.
For anyone with a shell and Python installed - I'd guess most of us?
$ python3
>>> import this
The Zen of Python
Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be oneβ and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than right now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea β let's do more of those!

Some of the above guidelines - and I use the word guidelines instead of rules on purpose. Fit more to Python than PHP, which is to be expected. Some apparently only make sense if you're likely to be taller than the average European and live in a very flat country (π³π±β€οΈ)
In my opinion there are no hard and fast rules in software engineering. Only guidelines. No single "rule" can ever take into account all the edge cases and weirdness you will come across
I'll admit at the time I first read this, I didn't really have the experience to fully grasp it's meaning.
Years later I did have that experience, reading it again in the last few years I realise it actually sums up fairly accurately my approach to software engineering.
Below I'm going to share some interpretations that align with my own
- The need for your code to be clear and easy to understand rather than relying on unspoken assumptions or hidden rules
- itβs better for code to be verbose and explicit. You should avoid hiding codeβs functionality behind obscure language features that require familiarity to fully understand.
- Someone with no prior knowledge of your program should still be able to understand what is happening in your code.
- The simplest solutions are often the most elegant and efficient.
- Simplicity may not always be possible, though, as some systems are complex by nature, consisting of many moving parts and layers. But that doesnβt mean they have to be complicated or difficult to understand.
- Code is read more often than itβs written, so explicit, readable code is more important than terse, undocumented code.
- Your job is to write the code once. However, there is a good chance it is going to be read a number of times.
- "Readability Counts" means you should put special care into optimizing for the reader rather than the writer.
- Programming is full of βbest practicesβ that programmers should strive for in their code. Skirting these practices for a quick hack may be tempting, but can lead to a ratβs nest of inconsistent, unreadable code. However, bending over backwards to adhere to rules can result in highly-abstract, unreadable code.
- Just because programmers often ignore error messages doesnβt mean the program should stop emitting them
- It turns out that having three or four different ways to write code that does the same thing is a double-edged sword: you have flexibility in how you write code, but now you have to learn every possible way it could have been written in order to read it.
- Computers do only what we tell them to do: if your code is not behaving as you would like it to, it is because it is doing what you have told it to do. Trying to fix the behavior by blindly attempting several different solutions until one works is a poor strategy
- It is not good enough if you are able to understand your own implementations - βI know what Iβm trying to sayβ does not cut it. Programming is a team activity, and if you are unable to explain your implementation to your teammates, then it is very likely that you have made the solution too complicated.
Hopefully the above will go some way to explaining the way I reason about the code I write and some of the arguments I put forth.



Eloquent
Active record pattern
Eloquent is Laravel's persistence layer, as you can probably guess by now it's included with the framework (Laravel is very much - batteries included), it also appears to be usable as a standalone package though I've never personally seen it done or confirmed it.
Laravel includes Eloquent, an object-relational mapper (ORM) that makes it enjoyable to interact with your database. When using Eloquent, each database table has a corresponding "Model" that is used to interact with that table. In addition to retrieving records from the database table, Eloquent models allow you to insert, update, and delete records from the table as well.
Eloquent is based on the active-record pattern. I'm not going to cover this in huge detail with my words as there's no shortage of information on the positives and negatives of either pattern I'll cover here.


What I will do is show some examples of how it works
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SignUp extends Model
{
//
}
All you need to create an Eloquent model is a class that extends Illuminate\Database\Eloquent\Model
Eloquent will make the assumption that the model SignUp
, will have a sign_ups
table.
This is the table we will go on to create and link to our Eloquent model.
MariaDB [database]> DESCRIBE sign_ups;
+-----------------+---------------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-----------------+---------------------+------+-----+---------+----------------+
| id | bigint(20) unsigned | NO | PRI | NULL | auto_increment |
| name | varchar(255) | NO | | NULL | |
| email | varchar(255) | NO | | NULL | |
| age | int(11) | NO | | NULL | |
| country | varchar(3) | YES | | NULL | |
| allow_marketing | tinyint(1) | NO | | NULL | |
+-----------------+---------------------+------+-----+---------+----------------+
6 rows in set (0.002 sec)
If you use the SignUp
model to fetch a record from the database it'll look like this
> SignUp::find(1);
= App\Models\SignUp {#6182
id: 1,
name: "Fred Flintstone",
email: "fred@flintstones.com",
age: 42,
country: "GBR",
allow_marketing: 0,
}
It's a 1-1 mapping with the database.
Model property name = database field name
If I change which database table the model is mapped to
class SignUp extends Model
{
protected $table = 'migrations';
}
My model has properties that map to the migrations
table
> SignUp::find(1);
= App\Models\SignUp {#5222
id: 1,
migration: "0001_01_01_000000_create_users_table",
batch: 1,
}
One of the first things I often end up doing when I start a new contract role on a Laravel project is this
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property string $name
* @property string $email
* @property int $age
* @property string|null $country
* @property bool $allow_marketing
*/
class SignUp extends Model
{
}
"Someone with no prior knowledge of your program should still be able to understand what is happening in your code"
So our Eloquent model maps to a record in the database, simple enough.
It's also the mechanism for connecting to the database and querying for/modifying data.
> $signUp = SignUp::find(1);
= App\Models\SignUp {#5237
id: 1,
migration: "0001_01_01_000000_create_users_table",
batch: 1,
}
> $signUp::where('id', 2)->get();
= Illuminate\Database\Eloquent\Collection {#5214
all: [
App\Models\SignUp {#5211
id: 2,
migration: "0001_01_01_000001_create_cache_table",
batch: 1,
},
],
}
Don't do this please
The models are really quite complex objects. But the things you'll see and use most often will be along the lines of
Model::find();
Model::findOrFail();
Model::create():
Model::update();
Model::updateOrCreate();
Model::where();
Implementation
I'm going to create the Model/Entity the same way for both frameworks, by using the CLI.
$ artisan make:model
β What should the model be named? ββββββββββββββββββββββββββββββ
β SignUp β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Would you like any of the following? βββββββββββββββββββββββββ
β Migration β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
INFO Model [app/Models/SignUp.php] created successfully.
INFO Migration [database/migrations/2025_03_13_185942_create_sign_ups_table.php] created successfully.
the generated model looks like
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SignUp extends Model
{
//
}
and the migration
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('sign_ups', function (Blueprint $table) {
$table->id();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sign_ups');
}
};
Not much to explain here - I've got a model class and a default migration with the correct table name specified.
The specification detailed what the database fields should be called and set limits on string lengths so it will inform the migration.
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('sign_ups', function (Blueprint $table) {
$table->id();
$table->string('name', length: 255);
$table->string('email', length: 255);
$table->integer('age');
$table->string('country', length: 3)->nullable();
$table->boolean('allow_marketing');
});
}
So I've got my model and written my migration. Next step is to run the migration to create the database table - worth noting on a new Laravel installation there are a bunch of migrations included for creating a users, cache and a jobs table. I don't need them but they're not going to get in the way.
$ php artisan migrate
INFO Running migrations.
2025_03_13_185942_create_sign_ups_table .............................................................................................. 8.93ms DONE

I'm going to manually add a record into the DB and then use a useful little tool that comes with Laravel named Tinker.
$ php artisan tinker
Psy Shell v0.12.7 (PHP 8.3.17 β cli) by Justin Hileman
> use App\Models\SignUp
> SignUp::all();
= Illuminate\Database\Eloquent\Collection {#1308
all: [
App\Models\SignUp {#5963
id: 1,
name: "Fred Flintstone",
email: "fred@flintstones.com",
age: 42,
country: "GBR",
allow_marketing: 0,
},
],
}
> $fred = SignUp::find(1);
= App\Models\SignUp {#6182
id: 1,
name: "Fred Flintstone",
email: "fred@flintstones.com",
age: 42,
country: "GBR",
allow_marketing: 0,
}
> echo $fred->name;
Fred Flintstoneβ
Looks good to me.
Doctrine
Object mapper pattern
Doctrine
Doctrine is the default persistence layer for Symfony, as you're probably used to by now, Symfony unlike Laravel doesn't come with batteries, so you'll have to install it yourself.
Doctrine ORM is an object-relational mapper (ORM) for PHP that provides transparent persistence for PHP objects. It uses the Data Mapper pattern at the heart, aiming for a complete separation of your domain/business logic from the persistence in a relational database management system.
The benefit of Doctrine for the programmer is the ability to focus on the object-oriented business logic and worry about persistence only as a secondary problem. This doesn't mean persistence is downplayed by Doctrine 2, however it is our belief that there are considerable benefits for object-oriented programming if persistence and entities are kept separated.
It lives under a different namespace to symfony/*
but it's always been the default ORM for Symfony. It is possible to use Doctrine in a standalone project.
$ composer require doctrine/doctrine-bundle
Doctrine uses a different pattern to Eloquent - object-relational mapping
Again I won't go into huge detail as there's plenty of it online already. The end result between Eloquent and Doctrine has it's differences but broadly speaking they both map PHP objects to data in a database.
Object-relational mapping (ORM) is a programming technique that facilitates seamless data exchange between relational databases and object-oriented programming languages, acting as a bridge between the two paradigms
However in Doctrine the mapped PHP classes are not capable of making database connections and queries - the concerns are seperated. Instead Doctrine uses Repositories
and an EntityManager
β these concepts will be clearer with some examples.



Laravel as standard comes with a bunch of artisan:make
commands, one of which I used to create the Eloquent model. With Symfony - the same functionality exists but you need to install it yourself (unsurprisingly)
$ composer require --dev symfony/maker-bundle
Where as Eloquent uses the name Model
, Doctine uses the name Entity
$ php bin/console make:entity
Class name of the entity to create or update (e.g. DeliciousChef):
> SignUp
created: src/Entity/SignUp.php
created: src/Repository/SignUpRepository.php
Entity generated! Now let's add some fields!
You can always add more fields later manually or by re-running this command.
New property name (press <return> to stop adding fields):
> name
Field type (enter ? to see all types) [string]:
> string
Field length [255]:
> 255
Can this field be null in the database (nullable) (yes/no) [no]:
> no
updated: src/Entity/SignUp.php
....
Add another property? Enter the property name (or press <return> to stop adding fields):
>
Success!
Next: When you're ready, create a migration with php bin/console make:migration
Worth noting this for future use
You can always add more fields later manually or by re-running this command.
Afterwards we have both an Entity and a Repository.
<?php
namespace App\Entity;
use App\Repository\SignUpRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: SignUpRepository::class)]
class SignUp
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\Column(length: 255)]
private ?string $email = null;
#[ORM\Column]
private ?int $age = null;
#[ORM\Column(length: 3, nullable: true)]
private ?string $country = null;
#[ORM\Column]
private ?bool $marketingOptIn = null;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getAge(): ?int
{
return $this->age;
}
public function setAge(int $age): static
{
$this->age = $age;
return $this;
}
public function getCountry(): ?string
{
return $this->country;
}
public function setCountry(?string $country): static
{
$this->country = $country;
return $this;
}
public function isMarketingOptIn(): ?bool
{
return $this->marketingOptIn;
}
public function setMarketingOptIn(bool $marketingOptIn): static
{
$this->marketingOptIn = $marketingOptIn;
return $this;
}
}
<?php
namespace App\Repository;
use App\Entity\SignUp;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<SignUp>
*/
class SignUpRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, SignUp::class);
}
// /**
// * @return SignUp[] Returns an array of SignUp objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('s')
// ->andWhere('s.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('s.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?SignUp
// {
// return $this->createQueryBuilder('s')
// ->andWhere('s.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}
As you can see the console command has created an Entity class with attributes that specify things like the column length. Not only that but the object has defined properties and getters/setters.
Doctrine like Eloquent will make assumptions about what the table and column names should be, based on the class and property names.
On running the make:migration
command, I get an error that tells me to install doctrine/doctrine-migrations-bundle
$ composer require doctrine/doctrine-migrations-bundle
$ php bin/console make:migration
created: migrations/Version20250313203505.php
Success!
Review the new migration then run it with php bin/console doctrine:migrations:migrate
final class Version20250313203505 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('
CREATE TABLE sign_up
(
id INT AUTO_INCREMENT NOT NULL,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
age INT NOT NULL,
country VARCHAR(3) DEFAULT NULL,
marketing_opt_in TINYINT(1) NOT NULL,
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE sign_up');
}
}
$ php bin/console doctrine:migrations:migrate
WARNING! You are about to execute a migration in database "database" that could result in schema changes and data loss. Are you sure you wish to continue? (yes/no) [yes]:
> yes
[notice] Migrating up to DoctrineMigrations\Version20250313203505
[notice] finished in 10ms, used 12M memory, 1 migrations executed, 1 sql queries
[OK] Successfully migrated to version: DoctrineMigrations\Version20250313203505
So far so good, no need to write a migration file manually like in Laravel.
Well almost -
You'll see that I specified the marketing field/property to be the same as in the Request DTO
private ?bool $marketingOptIn = null;
However the specification says the database field needs to be allow_marketing
That can be achieved with
#[ORM\Column(name: 'allow_marketing')]
private ?bool $marketingOptIn = null;
$ php bin/console doctrine:schema:validate
Mapping
-------
[OK] The mapping files are correct.
Database
--------
[ERROR] The database schema is not in sync with the current mapping file.
Where as with Eloquent the database fields create the properties on the Model. With Doctrine the source of truth is the entities. This allows you to create your schema in the way you're going to interact with it - in code. It also means migrations can be generated automatically - probably the thing I miss the most when working with Laravel and Eloquent.
However I'm just working on this on my local machine in a docker container, I don't need to be creating migration files for every little change I make.
$ php bin/console doctrine:schema:update --dump-sql // sanity check
ALTER TABLE sign_up CHANGE marketing_opt_in allow_marketing TINYINT(1) NOT NULL;
$ php bin/console doctrine:schema:update --force
Updating database schema...
1 query was executed
[OK] Database schema updated successfully!
Symfony doesn't have a tool like Tinker. So to recreate the same as with Laravel I'll create a console command and manually enter the same data as I did before.
class TestCommand extends Command
{
public function __construct(private SignUpRepository $repository)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$fred = $this->repository->find(1);
dd($fred);
}
}
$ php bin/console TestCommand
^ App\Entity\SignUp^ {#4652
-id: 1
-name: "Fred Flintstone"
-email: "fred@flintstone.com"
-age: 42
-country: "GBR"
-marketingOptIn: false
}
Pretty much the same result as Laravel - just a different way of going about it.
Conclusion
In both Laravel and Symfony - we can now persist data to a database. We also have a mechanism for retrieving data. In Symfony we have more code (but it was all generated for us), in Laravel, less code but we had to write a migration.
Basically we're at the same place in each framework. On the face of it at least...
Comments ()