Mapping with Attributes
How PHP attributes tell Doctrine which property becomes which column, and why length, nullable and type are decisions.
#[ORM\Column(type: Types::STRING, length: 255, nullable: false, unique: false)]An entity is an ordinary PHP class whose objects can be stored as rows in a database table. Doctrine is the library that does the storing. It has to be told which property belongs in which column, and that description is called the mapping.
Doctrine ORM 3, the version used with Symfony 8.1, reads the mapping from PHP attributes on the class. The older docblock annotation form was removed, so #[ORM\Entity] is the only spelling that works.
What the attributes say
#[ORM\Entity] marks the class as storable. #[ORM\Table(name: 'article')] names the table when the default name is not what you want. Each property you want stored carries #[ORM\Column]. A property without one is ignored, which is how a cached or computed value stays out of the database.
A bare #[ORM\Column] is shorthand. Doctrine reads the property's type declaration and picks the column type from it. On a string property it means exactly the same as the longhand #[ORM\Column(type: Types::STRING, length: 255, nullable: false)]. Both describe one column; the short form is what you will see in most code.
Choosing instead of defaulting
The defaults are a guess. A slug is not 255 characters wide. An article body is not a VARCHAR at all; it is Types::TEXT. Money needs precision and scale. The column type is the part of your data model the database itself will enforce, so it is worth stating deliberately.
The usual surprise is nullable, which defaults to false. A ?string property does not change that. The PHP type and the column are two separate declarations, and you have to make them agree.
Example
<?php
// src/Entity/Article.php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'article')]
class Article
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
// Shorthand: a bare #[ORM\Column] takes the column type from the
// property's type declaration. For a string property the longhand is:
// #[ORM\Column(type: Types::STRING, length: 255, nullable: false)]
// Here the width is a decision, not the 255-character default.
#[ORM\Column(length: 180, unique: true)]
private string $slug;
#[ORM\Column(length: 180)]
private string $title;
// "Absent" is a real value for a summary, so both sides must say so:
// ?string in PHP and nullable: true in the column.
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $summary = null;
// options is passed through to the schema: DEFAULT 0 in the DDL.
#[ORM\Column(options: ['default' => 0])]
private int $viewCount = 0;
// No #[ORM\Column], so this is never written to the table.
private ?string $renderedHtml = null;
public function __construct(string $slug, string $title)
{
$this->slug = $slug;
$this->title = $title;
}
public function getId(): ?int
{
return $this->id;
}
}When to use it
- Add a slug column with a sensible width and a unique index to an existing entity.
- Keep a computed display value on the object without creating a column for it.
- Store a long body as TEXT instead of accepting the default VARCHAR(255).
More examples
Columns the application should not write
Two options people reach for late. Backticks around a reserved word stop the generated SQL being a syntax error, and insertable/updatable set to false hand a column to the database so the application cannot overwrite it. Reach for the second one for audit columns filled by a default or a trigger.
<?php
// src/Entity/LedgerLine.php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class LedgerLine
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
// "order" is reserved in SQL. Backticks in the name tell Doctrine to
// quote it for whichever database platform is in use.
#[ORM\Column(name: '`order`', type: Types::INTEGER)]
private int $position;
// The database maintains this column. insertable/updatable false keep it
// out of every INSERT and UPDATE Doctrine builds, so the default and any
// trigger stay in charge of the value.
#[ORM\Column(
type: Types::DATETIME_IMMUTABLE,
insertable: false,
updatable: false,
options: ['default' => 'CURRENT_TIMESTAMP'],
)]
private ?\DateTimeImmutable $recordedAt = null;
public function __construct(int $position)
{
$this->position = $position;
}
}The table the mapping describes
Reading the mapping as a table makes two things obvious: there is no column for the unmapped property, and the property named viewCount became view_count because Doctrine's default naming converts camelCase to snake_case. Use this view whenever a column name in an error message does not look like the property you wrote.
-- The schema Doctrine derives from the Article mapping above (PostgreSQL).
-- Compare each line with the attribute that produced it.
CREATE TABLE article (
id SERIAL NOT NULL,
slug VARCHAR(180) NOT NULL,
title VARCHAR(180) NOT NULL,
summary TEXT DEFAULT NULL,
view_count INT DEFAULT 0 NOT NULL,
PRIMARY KEY(id)
);
CREATE UNIQUE INDEX uniq_article_slug ON article (slug);Telling Doctrine where entities live
The mapping block says which directory is scanned and which namespace those classes use. This matters because a class placed outside dir is simply not seen: the attributes are there, the entity is not. When a new entity is 'not a mapped class', check this file before the attributes.
# config/packages/doctrine.yaml
doctrine:
orm:
mappings:
App:
type: attribute # attributes, not XML and not annotations
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
is_bundle: false
Discussion