Auditing a Legacy Symfony Project: Where to Start Without Doing Everything Twice

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: stringenum, intenum, string date → DateTimeInterface, "Y"/"N"bool
  • Doctrine traits: Loggable + Versioned on business entities, SoftDeleteable on clinical entities, Timestampable everywhere
  • Nullability: a nullable field 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.

Comments (0)