Home PHP Mastering Doctrine ORM in Symfony: Advanced Guide
Advanced 4 min · July 13, 2026

Mastering Doctrine ORM in Symfony: Advanced Guide

Learn Doctrine ORM in Symfony with advanced techniques: associations, DQL, performance optimization, and production debugging.

N
Naren Founder & Principal Engineer

20+ years shipping production PHP systems at scale. Drawn from code that ran under real load.

Follow
Production
production tested
July 13, 2026
last updated
2,073
articles · all by Naren
Before you start⏱ 25-30 min read
  • Basic knowledge of PHP and Symfony framework
  • Understanding of database concepts (tables, relationships)
  • Composer installed
  • Symfony project set up
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Doctrine ORM maps PHP objects to database tables, simplifying data persistence.
  • Use annotations, YAML, or XML for entity mapping.
  • Manage associations (OneToMany, ManyToMany) with fetch modes.
  • Optimize queries with DQL, QueryBuilder, and lazy/eager loading.
  • Handle transactions, migrations, and caching for production.
✦ Definition~90s read
What is Doctrine ORM in Symfony?

Doctrine ORM is a PHP object-relational mapper that allows you to work with databases using PHP objects, abstracting away SQL and providing powerful features like associations, DQL, and lifecycle events.

Think of Doctrine ORM as a translator between your PHP objects and database tables.
Plain-English First

Think of Doctrine ORM as a translator between your PHP objects and database tables. Instead of writing SQL queries, you work with PHP objects, and Doctrine automatically converts them to database operations. It's like having a personal assistant who understands both English and French, so you can speak English and they handle the translation.

Doctrine ORM is the heart of data persistence in Symfony, providing a powerful abstraction layer over your database. It allows you to work with PHP objects without writing SQL, handling everything from simple CRUD to complex associations and inheritance. For advanced developers, mastering Doctrine means optimizing performance, avoiding N+1 queries, and managing transactions effectively.

In this tutorial, we'll dive deep into Doctrine's core concepts: entities, associations, DQL, QueryBuilder, and lifecycle events. You'll learn how to design efficient database schemas, leverage caching, and debug production issues. We'll also cover real-world scenarios like handling large datasets and preventing common pitfalls.

By the end, you'll be able to build high-performance Symfony applications with Doctrine ORM, ready for production.

Setting Up Doctrine ORM in Symfony

To start using Doctrine ORM in Symfony, install the ORM pack and configure your database connection. Use the MakerBundle to generate entities quickly.

``bash composer require symfony/orm-pack composer require --dev symfony/maker-bundle ``

``env DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name" ``

``bash php bin/console doctrine:database:create ``

``bash php bin/console make:entity Product ``

This creates a Product entity class with properties and getters/setters. Doctrine uses annotations by default to map the class to a database table.

```php // src/Entity/Product.php namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity] #[ORM\Table(name: 'product')] class Product { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] private int $id;

#[ORM\Column(type: 'string', length: 255)] private string $name;

#[ORM\Column(type: 'float')] private float $price;

// getters and setters } ```

``bash php bin/console make:migration php bin/console doctrine:migrations:migrate ``

Now you have a working Doctrine setup.

src/Entity/Product.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'product')]
class Product
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private int $id;

    #[ORM\Column(type: 'string', length: 255)]
    private string $name;

    #[ORM\Column(type: 'float')]
    private float $price;

    public function getId(): int
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name): self
    {
        $this->name = $name;
        return $this;
    }

    public function getPrice(): float
    {
        return $this->price;
    }

    public function setPrice(float $price): self
    {
        $this->price = $price;
        return $this;
    }
}
💡Use Attributes for Mapping
📊 Production Insight
Always run migrations in production with a backup. Use doctrine:migrations:migrate --dry-run to preview.
🎯 Key Takeaway
Doctrine entities are plain PHP objects with mapping metadata. Use MakerBundle to speed up creation.

Entity Associations: OneToMany and ManyToMany

Associations define relationships between entities. The most common are OneToMany (e.g., Category has many Products) and ManyToMany (e.g., Product has many Tags).

Let's create a Category entity with a OneToMany relationship to Product.

``bash php bin/console make:entity Category ``

```php // src/Entity/Category.php use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection;

#[ORM\Entity] class Category { // ...

#[ORM\OneToMany(mappedBy: 'category', targetEntity: Product::class, cascade: ['persist', 'remove'])] private Collection $products;

public function __construct() { $this->products = new ArrayCollection(); }

public function getProducts(): Collection { return $this->products; }

public function addProduct(Product $product): self { if (!$this->products->contains($product)) { $this->products[] = $product; $product->setCategory($this); } return $this; }

public function removeProduct(Product $product): self { if ($this->products->removeElement($product)) { if ($product->getCategory() === $this) { $product->setCategory(null); } } return $this; } } ```

```php // src/Entity/Product.php #[ORM\ManyToOne(targetEntity: Category::class, inversedBy: 'products')] #[ORM\JoinColumn(nullable: false)] private ?Category $category = null;

public function getCategory(): ?Category { return $this->category; }

public function setCategory(?Category $category): self { $this->category = $category; return $this; } ```

```php // src/Entity/Tag.php #[ORM\Entity] class Tag { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] private int $id;

#[ORM\Column(type: 'string', length: 255)] private string $name;

#[ORM\ManyToMany(targetEntity: Product::class, mappedBy: 'tags')] private Collection $products;

public function __construct() { $this->products = new ArrayCollection(); } } ```

```php #[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'products', cascade: ['persist'])] #[ORM\JoinTable(name: 'product_tag')] private Collection $tags;

public function __construct() { $this->tags = new ArrayCollection(); } ```

Always initialize collections in the constructor. Use cascade options carefully to avoid unintended deletions.

src/Entity/Category.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<?php

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Category
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private int $id;

    #[ORM\Column(type: 'string', length: 255)]
    private string $name;

    #[ORM\OneToMany(mappedBy: 'category', targetEntity: Product::class, cascade: ['persist', 'remove'])]
    private Collection $products;

    public function __construct()
    {
        $this->products = new ArrayCollection();
    }

    public function getId(): int
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name): self
    {
        $this->name = $name;
        return $this;
    }

    public function getProducts(): Collection
    {
        return $this->products;
    }

    public function addProduct(Product $product): self
    {
        if (!$this->products->contains($product)) {
            $this->products[] = $product;
            $product->setCategory($this);
        }
        return $this;
    }

    public function removeProduct(Product $product): self
    {
        if ($this->products->removeElement($product)) {
            if ($product->getCategory() === $this) {
                $product->setCategory(null);
            }
        }
        return $this;
    }
}
⚠ Cascade Remove Danger
📊 Production Insight
For large collections, avoid cascade remove; use soft deletes or manual cleanup to prevent accidental data loss.
🎯 Key Takeaway
Associations require both sides to be properly mapped. Always update the owning side to maintain consistency.

DQL and QueryBuilder: Advanced Querying

Doctrine Query Language (DQL) is an object-oriented query language similar to SQL but works with entities. QueryBuilder provides a fluent interface to build queries dynamically.

Example: Find all products with a price greater than 100, ordered by name.

``php $entityManager = $this->getDoctrine()->getManager(); $query = $entityManager->createQuery( 'SELECT p FROM App\Entity\Product p WHERE p.price > :price ORDER BY p.name ASC' )->setParameter('price', 100); $products = $query->getResult(); ``

``php $repository = $entityManager->getRepository(Product::class); $queryBuilder = $repository->createQueryBuilder('p') ->where('p.price > :price') ->setParameter('price', 100) ->orderBy('p.name', 'ASC'); $products = $queryBuilder->getQuery()->getResult(); ``

``php $queryBuilder = $entityManager->createQueryBuilder() ->select('p', 'c') ->from('App\Entity\Product', 'p') ->leftJoin('p.category', 'c') ->where('c.name = :categoryName') ->setParameter('categoryName', 'Electronics'); ``

Always use parameters to prevent SQL injection. Avoid string concatenation.

``php $query = $repository->createQueryBuilder('p') ->setFirstResult(0) ->setMaxResults(10) ->getQuery(); ``

Consider using KnpPaginatorBundle for advanced pagination.

src/Repository/ProductRepository.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?php

namespace App\Repository;

use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class ProductRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Product::class);
    }

    public function findExpensiveProducts(float $price): array
    {
        return $this->createQueryBuilder('p')
            ->andWhere('p.price > :price')
            ->setParameter('price', $price)
            ->orderBy('p.name', 'ASC')
            ->getQuery()
            ->getResult();
    }

    public function findProductsByCategory(string $categoryName): array
    {
        return $this->createQueryBuilder('p')
            ->leftJoin('p.category', 'c')
            ->addSelect('c')
            ->andWhere('c.name = :categoryName')
            ->setParameter('categoryName', $categoryName)
            ->getQuery()
            ->getResult();
    }
}
💡Use addSelect for Eager Loading
📊 Production Insight
In production, enable SQL logging only temporarily. Use doctrine:query:sql for debugging.
🎯 Key Takeaway
DQL and QueryBuilder are powerful tools for complex queries. Always use parameters and consider pagination for large datasets.

Performance Optimization: Caching and Indexing

Doctrine provides several caching layers: metadata cache, query cache, and result cache. Use APCu or Redis for production.

``yaml doctrine: orm: metadata_cache_driver: type: pool pool: doctrine.system_cache_pool query_cache_driver: type: pool pool: doctrine.system_cache_pool result_cache_driver: type: pool pool: doctrine.result_cache_pool ``

``yaml framework: cache: pools: doctrine.result_cache_pool: adapter: cache.app doctrine.system_cache_pool: adapter: cache.system ``

``php $query = $repository->createQueryBuilder('p') ->getQuery() ->useResultCache(true, 3600, 'products_all'); ``

Database indexing is crucial. Add indexes in entity annotations:

``php #[ORM\Entity] #[ORM\Table(name: 'product')] #[ORM\Index(name: 'idx_product_price', columns: ['price'])] class Product { // ... } ``

Generate migration to create indexes.

``php $batchSize = 20; for ($i = 1; $i <= 10000; $i++) { $product = new Product(); $product->setName('Product '.$i); $product->setPrice(mt_rand(10, 1000)); $entityManager->persist($product); if (($i % $batchSize) === 0) { $entityManager->flush(); $entityManager->clear(); } } $entityManager->flush(); ``

This prevents memory exhaustion.

config/packages/doctrine.yamlYAML
1
2
3
4
5
6
7
8
9
10
11
12
doctrine:
    orm:
        auto_generate_proxy_classes: true
        metadata_cache_driver:
            type: pool
            pool: doctrine.system_cache_pool
        query_cache_driver:
            type: pool
            pool: doctrine.system_cache_pool
        result_cache_driver:
            type: pool
            pool: doctrine.result_cache_pool
🔥Cache Invalidation
📊 Production Insight
Monitor cache hit ratio in production. Use Redis for distributed caching across multiple servers.
🎯 Key Takeaway
Caching and indexing are essential for performance. Use batch processing for bulk operations.

Transactions and Concurrency Control

Doctrine supports database transactions to ensure data integrity. Use flush() within a transaction to commit changes atomically.

Example: Transfer money between accounts:

```php $entityManager->beginTransaction(); try { $accountFrom = $entityManager->find(Account::class, 1); $accountTo = $entityManager->find(Account::class, 2);

$accountFrom->setBalance($accountFrom->getBalance() - 100); $accountTo->setBalance($accountTo->getBalance() + 100);

$entityManager->flush(); $entityManager->commit(); } catch (\Exception $e) { $entityManager->rollback(); throw $e; } ```

``php #[ORM\Version] #[ORM\Column(type: 'integer')] private int $version; ``

Doctrine will throw OptimisticLockException if version mismatch occurs.

``php $entityManager->lock($product, LockMode::PESSIMISTIC_WRITE); ``

This prevents concurrent updates.

Always handle exceptions and rollback on failure.

src/Service/AccountService.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?php

namespace App\Service;

use App\Entity\Account;
use Doctrine\ORM\EntityManagerInterface;

class AccountService
{
    private EntityManagerInterface $entityManager;

    public function __construct(EntityManagerInterface $entityManager)
    {
        $this->entityManager = $entityManager;
    }

    public function transfer(int $fromId, int $toId, float $amount): void
    {
        $this->entityManager->beginTransaction();
        try {
            $accountFrom = $this->entityManager->find(Account::class, $fromId);
            $accountTo = $this->entityManager->find(Account::class, $toId);

            if (!$accountFrom || !$accountTo) {
                throw new \InvalidArgumentException('Account not found');
            }

            $accountFrom->setBalance($accountFrom->getBalance() - $amount);
            $accountTo->setBalance($accountTo->getBalance() + $amount);

            $this->entityManager->flush();
            $this->entityManager->commit();
        } catch (\Exception $e) {
            $this->entityManager->rollback();
            throw $e;
        }
    }
}
⚠ Transaction Isolation
📊 Production Insight
In high-traffic systems, use optimistic locking to avoid deadlocks. Retry on OptimisticLockException.
🎯 Key Takeaway
Transactions ensure atomicity. Use optimistic locking for concurrency control without long locks.

Lifecycle Events and Event Listeners

Doctrine lifecycle events allow you to execute code when entities are persisted, updated, or removed. Common events: prePersist, postPersist, preUpdate, postUpdate, preRemove, postRemove.

Example: Automatically set createdAt timestamp.

``php #[ORM\Entity] #[ORM\HasLifecycleCallbacks] class Product { #[ORM\PrePersist] public function setCreatedAtValue(): void { $this->createdAt = new \DateTimeImmutable(); } } ``

```php // src/EventListener/ProductSubscriber.php namespace App\EventListener;

use App\Entity\Product; use Doctrine\Bundle\DoctrineBundle\EventSubscriber\EventSubscriberInterface; use Doctrine\ORM\Events; use Doctrine\Persistence\Event\LifecycleEventArgs;

class ProductSubscriber implements EventSubscriberInterface { public function getSubscribedEvents(): array { return [ Events::prePersist, Events::preUpdate, ]; }

public function prePersist(LifecycleEventArgs $args): void { $entity = $args->getObject(); if (!$entity instanceof Product) { return; } $entity->setCreatedAt(new \DateTimeImmutable()); }

public function preUpdate(LifecycleEventArgs $args): void { $entity = $args->getObject(); if (!$entity instanceof Product) { return; } $entity->setUpdatedAt(new \DateTimeImmutable()); } } ```

``yaml services: App\EventListener\ProductSubscriber: tags: - { name: doctrine.event_subscriber, connection: default } ``

Lifecycle events are powerful but can introduce side effects. Keep them lightweight.

src/EventListener/ProductSubscriber.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?php

namespace App\EventListener;

use App\Entity\Product;
use Doctrine\Bundle\DoctrineBundle\EventSubscriber\EventSubscriberInterface;
use Doctrine\ORM\Events;
use Doctrine\Persistence\Event\LifecycleEventArgs;

class ProductSubscriber implements EventSubscriberInterface
{
    public function getSubscribedEvents(): array
    {
        return [
            Events::prePersist,
            Events::preUpdate,
        ];
    }

    public function prePersist(LifecycleEventArgs $args): void
    {
        $entity = $args->getObject();
        if (!$entity instanceof Product) {
            return;
        }
        $entity->setCreatedAt(new \DateTimeImmutable());
    }

    public function preUpdate(LifecycleEventArgs $args): void
    {
        $entity = $args->getObject();
        if (!$entity instanceof Product) {
            return;
        }
        $entity->setUpdatedAt(new \DateTimeImmutable());
    }
}
💡Avoid Heavy Logic in Events
📊 Production Insight
Be careful with preUpdate: changes inside preUpdate may trigger another preUpdate, causing infinite loops.
🎯 Key Takeaway
Lifecycle events automate tasks like timestamps. Use subscribers for reusable logic.

Migrations and Schema Management

Doctrine Migrations allow version-controlled database schema changes. Generate migrations from entity changes.

``bash php bin/console make:migration ``

This generates a PHP class with up() and down() methods.

``bash php bin/console doctrine:migrations:up-to-date php bin/console doctrine:migrations:migrate --dry-run ``

``bash php bin/console doctrine:migrations:migrate ``

```php public function up(Schema $schema): void { $this->addSql('ALTER TABLE product ADD discount DOUBLE PRECISION DEFAULT NULL'); }

public function down(Schema $schema): void { $this->addSql('ALTER TABLE product DROP discount'); } ```

Always test migrations on a staging environment first.

``bash php bin/console doctrine:migrations:execute --down 'DoctrineMigrations\Version20230101000000' ``

Use version control for migration files.

migrations/Version20230101000000.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20230101000000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Add discount column to product table';
    }

    public function up(Schema $schema): void
    {
        $this->addSql('ALTER TABLE product ADD discount DOUBLE PRECISION DEFAULT NULL');
    }

    public function down(Schema $schema): void
    {
        $this->addSql('ALTER TABLE product DROP discount');
    }
}
🔥Migration Best Practices
📊 Production Insight
Automate migrations in deployment pipeline. Use doctrine:migrations:migrate --no-interaction for CI/CD.
🎯 Key Takeaway
Migrations provide version control for database schema. Always review SQL before applying.
● Production incidentPOST-MORTEMseverity: high

The N+1 Query Disaster: How a Blog Went Down

Symptom
The blog homepage took over 30 seconds to load, then timed out.
Assumption
The developer assumed lazy loading would be fine for a list of posts with authors.
Root cause
The Twig template looped over posts and accessed each post's author, triggering a separate query per post (N+1 problem).
Fix
Changed the repository query to use JOIN with eager loading (addSelect and join).
Key lesson
  • Always use JOINs when fetching related entities in loops.
  • Use Doctrine's QueryBuilder to explicitly join associations.
  • Monitor database queries with Symfony profiler.
Production debug guideSymptom to Action4 entries
Symptom · 01
Slow page load with many queries
Fix
Enable Symfony profiler and check Doctrine section for query count.
Symptom · 02
Memory exhaustion
Fix
Check for large collections; use lazy loading or pagination.
Symptom · 03
Stale data
Fix
Clear Doctrine cache: php bin/console doctrine:cache:clear-metadata
Symptom · 04
Deadlocks
Fix
Review transaction isolation levels and use explicit locking.
★ Quick Debug Cheat SheetCommon Doctrine issues and immediate actions.
Too many queries (N+1)
Immediate action
Add explicit JOIN in DQL or QueryBuilder
Commands
->leftJoin('p.author', 'a')->addSelect('a')
php bin/console debug:container --show-arguments doctrine
Fix now
Modify repository method to join related entities.
Entity not found+
Immediate action
Check if entity exists in database
Commands
php bin/console doctrine:query:sql 'SELECT * FROM post WHERE id=1'
Check entity mapping
Fix now
Ensure entity class is properly mapped and table exists.
Lazy loading outside transaction+
Immediate action
Ensure entity manager is open
Commands
php bin/console debug:container --parameters
Check for closed EntityManager
Fix now
Reopen EntityManager or fetch data before closing.
FeatureDoctrine ORMLaravel Eloquent
ApproachData Mapper (entities are plain objects)Active Record (model extends base class)
Query LanguageDQL (Doctrine Query Language)Query Builder (fluent interface)
AssociationsExplicit mapping with annotations/attributesConvention-based with methods
PerformanceMore control, better for complex queriesSimpler, but can be slower for large datasets
Learning CurveSteeper due to mapping and configurationEasier for beginners
⚙ Quick Reference
7 commands from this guide
FileCommand / CodePurpose
srcEntityProduct.phpnamespace App\Entity;Setting Up Doctrine ORM in Symfony
srcEntityCategory.phpnamespace App\Entity;Entity Associations
srcRepositoryProductRepository.phpnamespace App\Repository;DQL and QueryBuilder
configpackagesdoctrine.yamldoctrine:Performance Optimization
srcServiceAccountService.phpnamespace App\Service;Transactions and Concurrency Control
srcEventListenerProductSubscriber.phpnamespace App\EventListener;Lifecycle Events and Event Listeners
migrationsVersion20230101000000.phpdeclare(strict_types=1);Migrations and Schema Management

Key takeaways

1
Doctrine ORM maps PHP objects to database tables, providing a powerful abstraction layer.
2
Use associations (OneToMany, ManyToMany) with proper cascade options and fetch modes.
3
Optimize queries with DQL, QueryBuilder, and explicit JOINs to avoid N+1 problems.
4
Leverage caching (metadata, query, result) and database indexing for performance.
5
Use transactions and locking for data integrity, and lifecycle events for automation.
6
Manage schema changes with migrations and always test in staging.

Common mistakes to avoid

3 patterns
×

Not initializing collections in constructor

×

Using cascade remove on OneToMany without considering consequences

×

Forgetting to call flush() after persist

INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the Unit of Work pattern in Doctrine.
Q02SENIOR
How would you optimize a query that loads 1000 products with their categ...
Q03JUNIOR
What is the difference between lazy loading and eager loading?
Q01 of 03SENIOR

Explain the Unit of Work pattern in Doctrine.

ANSWER
The Unit of Work tracks all changes to entities during a request. It collects all persist, update, and delete operations and executes them in a single transaction when flush() is called. This ensures consistency and performance.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is the difference between persist and flush?
02
How do I avoid the N+1 query problem?
03
Can I use Doctrine without Symfony?
04
What is the best way to handle soft deletes?
N
Naren Founder & Principal Engineer

20+ years shipping production PHP systems at scale. Drawn from code that ran under real load.

Follow
Verified
production tested
July 13, 2026
last updated
2,073
articles · all by Naren
🔥

That's Advanced PHP. Mark it forged?

4 min read · try the examples if you haven't

Previous
Symfony vs Laravel: Choosing Your PHP Framework
22 / 29 · Advanced PHP
Next
PHPStan: Static Analysis for PHP