Your First Entity, End to End
Generate an entity with make:entity, turn it into a table with a migration, then write one row and read it back.
php bin/console make:entity ArticleThis is the whole loop once: a mapped class, a table that matches it, one row written, one row read.
Generate the class
php bin/console make:entity asks for a class name, then for fields one at a time: name, type, length, nullable. It writes src/Entity/Article.php and src/Repository/ArticleRepository.php. Run it again with the same class name and it adds fields to the existing class rather than starting over. The generated repository extends ServiceEntityRepository, which makes it an injectable service and gives you find(), findBy(), findOneBy() and findAll().
Describe the table
php bin/console make:migration compares your mapping against the live database and writes a class in migrations/ holding the SQL for the difference. Nothing has changed yet. php bin/console doctrine:migrations:migrate runs it and records the version in a tracking table, so it never runs twice.
Use it
Create the object, persist it, flush. Read it back through the repository. Two points of friction: the generated properties are nullable with a null default, because a freshly constructed object has no values yet; and the generated files do not include declare(strict_types=1), so add it if your project expects it.
Example
<?php
// src/Controller/ArticleController.php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Article;
use App\Repository\ArticleRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ArticleController extends AbstractController
{
#[Route('/articles/seed', name: 'app_article_seed', methods: ['POST'])]
public function seed(EntityManagerInterface $entityManager): Response
{
$article = new Article();
$article->setTitle('Hello Doctrine');
$article->setBody('One row, written by flush().');
$article->setCreatedAt(new DateTimeImmutable('2026-03-01 09:00:00'));
$entityManager->persist($article);
$entityManager->flush();
return new Response('Saved article '.$article->getId(), Response::HTTP_CREATED);
}
#[Route('/articles/{id<\d+>}', name: 'app_article_show', methods: ['GET'])]
public function show(int $id, ArticleRepository $articles): Response
{
// find() comes from ServiceEntityRepository: an Article, or null.
$article = $articles->find($id);
if ($article === null) {
throw $this->createNotFoundException('No article '.$id);
}
return new Response($article->getTitle());
}
}When to use it
- Add a table to a project and get the class, the repository and the SQL in three commands.
- Add a column to an existing entity without hand-writing the ALTER TABLE.
- Recreate the same schema on a colleague's machine from a clean checkout.
- Review exactly what SQL a schema change will run before it runs.
More examples
The make:entity session, and the same thing without prompts
The interactive form is easier the first time; the option form is the one for notes, scripts and pull-request descriptions, because it is reproducible. --field takes name[:type[:length]], and a trailing ? makes the column nullable. Both paths end in the same two files and the same reminder to create a migration.
$ php bin/console make:entity
Class name of the entity to create or update (e.g. BraveChef):
> Article
created: src/Entity/Article.php
created: src/Repository/ArticleRepository.php
New property name (press <return> to stop adding fields):
> title
Field type (enter ? to see all types) [string]:
>
Field length [255]:
> 160
Can this field be null in the database (nullable)? (yes/no) [no]:
>
updated: src/Entity/Article.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
# Shorthand: the fields as options, no prompts. Same generated files.
$ php bin/console make:entity Article \
--field title:string:160 --field body:text --field createdAt:datetime_immutableThe generated entity
Two details repay a close read. The repositoryClass argument is what ties the entity to the generated repository. And every property is ?type with a null default while the setters take non-null arguments: that is a compromise for objects built field by field, not a claim that the column is nullable. The column is NOT NULL unless you answered yes to the nullable prompt.
<?php
// src/Entity/Article.php (as make:entity writes it)
namespace App\Entity;
use App\Repository\ArticleRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
class Article
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 160)]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $body = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private ?\DateTimeImmutable $createdAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
// The other getters and setters match this shape.
}The generated migration
The file is SQL inside PHP: addSql(string $sql, array $params = [], array $types = []) queues a statement, up() applies the change and down() undoes it. The SQL is in the dialect of the database you are connected to, MySQL here. Generating the file rather than letting a tool alter the schema directly is the point: you can read and edit it before it runs.
<?php
// migrations/Version20260301090000.php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260301090000 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 article (id INT AUTO_INCREMENT NOT NULL, title VARCHAR(160) NOT NULL, body LONGTEXT NOT NULL, created_at DATETIME NOT NULL, PRIMARY KEY(id))');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE article');
}
}
// php bin/console doctrine:migrations:migrate
// [notice] Migrating up to DoctrineMigrations\Version20260301090000
// [OK] Successfully migrated to version: DoctrineMigrations\Version20260301090000
Discussion