Symfony vs Laravel - A humble request (Part 3)

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.

The Zen Of Python Explained With Examples - Code Conquest
This article discusses the zen of Python with examples to make understand how you can write better code in Python.
The Zen of Python, Explained
The Zen of Python by Tim Peters are 20 guidelines for the design of the Python language. Your Python code doesn’t necessarily have to follow these guidelines, but they’re good to keep in mind. The Zen of Python is an Easter egg, or hidden joke, that appears if you run import this.
What’s the Zen of Python? – Real Python
In this tutorial, you’ll be exploring the Zen of Python, a collection of nineteen guiding principles for writing idiomatic Python. You’ll find out how they originated and whether you should follow them. Along the way, you’ll uncover several inside jokes associated with this humorous poem.

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.

Eloquent: Getting Started - Laravel 11.x - The PHP Framework For Web Artisans
Laravel is a PHP web application framework with expressive, elegant syntax. We’ve already laid the foundation β€” freeing you to create without sweating the small things.
Active Record Basics β€” Ruby on Rails Guides
Active Record BasicsThis guide is an introduction to Active Record.After reading this guide, you will know: How Active Record fits into the Model-View-Controller (MVC) paradigm. What Object Relational Mapping and Active Record patterns are and how they are used in Rails. How to use Active Record models to manipulate data stored in a relational database. Active Record schema naming conventions. The concepts of database migrations, validations, callbacks, and associations.
Active Record: How We Got Persistence Perfectly Wrong

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.

Architecture - Doctrine Object Relational Mapper (ORM)
Doctrine Object Relational Mapper Documentation: Architecture
Understanding Object-Relational Mapping
An overview of ORMs, comparing them against SQL tools, and reviewing the pros and cons of these tools.
Doctrine 2 ORM Best Practices
Doctrine 2 ORM Best Practices

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...