We're auditing a Symfony MessageController. The CRUD looks standard —
create, update, delete, list. We start reviewing the code, noting security issues,
fat controllers, missing validations. And then we open the Message
entity.
The broadcast field is a string with values
"Y" and "N". The type field is an
int with no enum, magic numbers scattered across the controller.
The title field in PHP is called titre in the database.
And that's when the real question hits: are we auditing the controller, or are
we auditing the entity?
If we fix the controller now, we'll write code that checks
broadcast === "Y". When we fix the entity later to use a
bool, we'll have to come back and touch this controller again.
Same code, audited twice, modified twice. That's exactly what we want to avoid.
The three possible orders
Facing a legacy Symfony project with 15 entities, 20 controllers, a handful of services and listeners, there are three ways to organize an audit. None is universally bad — but only one avoids the systematic rework trap.
Top-down: controllers first
Start with the controllers. It's the intuitive approach — follow the HTTP flow,
see what the user sees. You discover security issues, missing
@IsGranted, fat controllers doing 300 lines of business logic.
The upside: it's concrete. You quickly spot which entities are underused, which
services are over-engineered. The downside: every controller you audit pulls you
back to the entities. You find a "Y"/"N" here, a meaningless
int type there, gratuitous nullables elsewhere. You spend your
time writing "fix in entity later" without ever fixing anything.
// What you find in the controller
if ($message->getBroadcast() === 'Y') {
$this->notificationService->sendToAll($message);
}
// What you'd want to write
if ($message->isBroadcast()) {
$this->notificationService->sendToAll($message);
}
But you can't write the second version until the entity changes. So you leave the first one. And when you do fix the entity, you'll have to come back and touch this controller.
Vertical slice: feature by feature, end to end
Pick a feature — say, message management — and audit everything at once: the
Message entity, the MessageController, the
MessageType form, the Twig template. Complete feature, shipped,
move on to the next.
This is the approach that most resembles a real PR review. Each slice is a coherent deliverable. On a mature project with established conventions, it's excellent.
On a legacy project where conventions don't exist yet, it's a trap.
Because cross-cutting rules — "all clinical entities need
SoftDeleteable", "Y/N strings become
bool", "every auditable entity gets Loggable +
Versioned" — you discover those rules as you go. And every slice
becomes a debate: "Should we add Loggable here too? Oh right, like the previous
entity." Multiply by 15 entities, and it gets exhausting.
Bottom-up: entities first
Start with the data model. Go through all 15 entities, align everything: types,
naming conventions, Doctrine traits (Loggable,
SoftDeleteable, Timestampable), validation assertions,
enums. Stabilize the foundations. Only then move up to services, listeners,
controllers.
It's the least glamorous approach. No visible deliverable during the first phase. You spend time "in the dark", fixing types and adding traits to entities. Nobody notices a difference on the user side.
But when you reach the controllers, everything underneath is clean. A
broadcast is a bool. A type is an
enum. A title is called title everywhere.
You audit control flow, security, separation of concerns — not impedance
mismatches between the controller and the entity.
Why bottom-up wins on legacy
The reasoning fits in one sentence: the data model dictates everything
above it. Symfony validations (@Assert) live on the entity.
Form type definitions depend on entity types. Controller conditions reflect the
entity's possible states. If the model is broken, everything above it is broken.
On a legacy project, entities accumulate silent technical debt. It's rarely
spectacular — no visible bugs, no crashes. It's just that the status
field is an int instead of an enum, that deletedAt
doesn't exist because deletions are hard deletes, that half the fields are
nullable for no reason.
And this debt propagates. A controller testing $status === 3
instead of $status === Status::ARCHIVED isn't a controller
problem — it's an entity problem that contaminated the controller.
The migration argument
"But modifying entities means Doctrine migrations." Yes. And it's easier than you think, as long as you do it early.
If the project is in refactoring phase (not yet in production, or with a dev database you can rebuild), migrations are free. If the project is in production, migrations are necessary anyway — the question isn't "do we migrate", it's "do we migrate now in a controlled way or later in a panic when a type bug blows up in our face".
The classic top-down trap
Here's the typical scenario. You audit PatientController. You find
security issues — missing @IsGranted, no check that the patient
belongs to the right clinic. You fix them. Good.
Then you open the Patient entity. No SoftDeleteable —
patients are hard-deleted, which is illegal in a medical context. The
gender field is a free-form string instead of an enum.
Birth dates are string fields formatted as
"DD/MM/YYYY".
// Patient entity — before audit
#[ORM\Column(type: 'string', length: 10)]
private ?string $gender = null;
#[ORM\Column(type: 'string', length: 10)]
private ?string $birthDate = null;
// Patient entity — after audit
#[ORM\Column(type: 'string', enumType: Gender::class)]
private Gender $gender;
#[ORM\Column(type: 'date')]
private \DateTimeInterface $birthDate;
This entity change breaks the controller you just audited. The
$patient->getGender() === 'M' comparisons no longer work. The
date formatting in templates breaks. You have to go back.
Bottom-up avoids this scenario: by the time you reach the controller, the entity is already clean.
The two-phase method
Pure bottom-up has a flaw: if you audit entities with no idea how they're used,
you risk over-engineering. Adding Loggable to a config entity that
changes once a year is just noise.
The solution is a quick pre-audit. Not a full audit — an inventory.
Phase 0: entity inventory (30 minutes)
Walk through src/Entity/. For each entity, one line: what's missing,
what's inconsistent, what's the business context.
| Entity | Structural issues | Priority |
|---|---|---|
Patient |
No SoftDelete, gender string, birthDate string | High |
Message |
broadcast Y/N, type int without enum, title/titre mismatch | Medium |
User |
Missing Loggable, roles in untyped JSON | High |
Config |
Technical entity — fine as is | Low |
Study |
No Versioned, status int, gratuitous nullables | High |
This inventory takes 30 minutes and changes everything. You know which entities are critical, which can wait, and most importantly — you detect cross-cutting patterns before writing a single line of code.
Phase 1: entity audit
Attack entities one by one, by priority. For each entity, the same checklist:
- Types:
string→enum,int→enum,stringdate →DateTimeInterface,"Y"/"N"→bool - Doctrine traits:
Loggable+Versionedon business entities,SoftDeleteableon clinical entities,Timestampableeverywhere - Nullability: a
nullablefield should be nullable for a business reason, not by default - Assertions:
@Assert\NotBlank,@Assert\Length,@Assert\Choice— validation lives on the entity, not in the controller - Naming: consistency between PHP property name and column name
// Before: the entity accumulates silent debt
#[ORM\Entity]
class Message
{
#[ORM\Column(type: 'string', length: 1)]
private ?string $broadcast = 'N';
#[ORM\Column(type: 'integer')]
private ?int $type = null;
#[ORM\Column(name: 'titre', type: 'string', length: 255)]
private ?string $title = null;
}
// After: the model expresses the business domain
#[ORM\Entity]
#[Gedmo\Loggable]
#[Gedmo\SoftDeleteable(fieldName: 'deletedAt')]
class Message
{
#[ORM\Column]
private bool $broadcast = false;
#[ORM\Column(type: 'string', enumType: MessageType::class)]
private MessageType $type;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
private string $title;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $deletedAt = null;
}
Phases 2, 3, 4: services → controllers → templates
Once entities are stabilized, move up layer by layer. Services and listeners
first — AccessControlListener,
PatientDataHistoryListener — because they depend directly on
entities. Then controllers, which now work with clean types. Finally templates,
with properly typed data.
At each layer, the audit focuses on that layer's real problems: security and separation of concerns for controllers, business logic for services, display and UX for templates. No more debating types — that's settled.
When vertical slice is the right choice
Bottom-up isn't always the answer. If the project already has solid conventions — clean types, Doctrine traits in place, consistent validation — then vertical slice is superior. You close out a feature end to end, ship it, move on. It's the natural workflow of a team doing PR reviews.
The question to ask: do the conventions already exist, or are we establishing them? If they exist, vertical slice. If you're discovering them as you audit, bottom-up. Establish cross-cutting rules once, cleanly, across all entities — then let the controllers conform mechanically.
Conclusion
The natural instinct on a legacy audit is to start with what's visible: the controller, the HTTP route, what the user sees. It's satisfying but it's a trap. You end up auditing facades built on foundations you're about to change.
In a legacy Symfony project, Doctrine entities are the foundations.
Stabilizing them first gives you the right to audit everything else exactly once.
It's less gratifying at first — nobody applauds a Loggable trait
added to 10 entities — but it's the only approach where the work doesn't get
redone.
The 30-minute inventory before starting is what turns an endless audit into an execution plan. You know what you'll touch, in what order, and why.