Some applications are campaign websites. They live fast, ship quickly, and retire quietly.

Enterprise applications are different.

They stick around. They collect business rules. They survive leadership changes, team changes, vendor changes, migrations, compliance requirements, and that one integration nobody wants to touch because it still sends XML over SFTP.

Symfony is good at this kind of work because it values stability, explicitness, components, and backward compatibility. But the framework cannot do the whole job for you.

Long-lived software is built with habits.

Build Around Contracts, Not Accidents

A contract is a promise between parts of your system. It can be an interface, DTO, message, API schema, event, or database constraint.

Without contracts, your app becomes a city where every building shares random plumbing with every other building. It works until someone repairs a sink and floods accounting.

PHP src/Billing/PaymentGateway.php
interface PaymentGateway
{
    public function charge(ChargeRequest $request): ChargeResult;
}

Now the application depends on a stable capability, not one vendor implementation.

PHP src/Billing/StripePaymentGateway.php
final class StripePaymentGateway implements PaymentGateway
{
    public function charge(ChargeRequest $request): ChargeResult
    {
        // Vendor-specific API call lives here.
    }
}

This makes vendor replacement, testing, and failure handling more realistic.

Use Symfony's BC Culture To Your Advantage

Symfony has a strong backward compatibility culture across minor releases, and Long-Term Support versions are designed for teams that need predictable maintenance windows.

That does not mean upgrades are free. It means the ecosystem gives you a sane path if you keep your app disciplined.

A long-lived Symfony app should treat deprecations like smoke alarms, not background noise.

Bash
composer outdated symfony/*
php bin/console debug:container --deprecations
vendor/bin/phpunit

Deprecations ignored for three years become migration debt with interest.

Keep Business Logic Away From Framework Glue

Controllers, commands, message handlers, and event subscribers are entry points. They should not become the business core.

A stable app puts important behavior in application services and domain classes.

PHP src/Application/ApproveRefund.php
final class ApproveRefund
{
    public function handle(ApproveRefundCommand $command): void
    {
        $refund = $this->refunds->get($command->refundId);
        $refund->approve($command->approvedBy);

        $this->refunds->save($refund);
    }
}

Now HTTP, CLI, queue, and admin UI can call the same use case without duplicating rules.

That is how long-lived systems avoid business logic archaeology.

A polished layered architecture diagram. Five horizontal layers stack top to bottom: HTTP / CLI / Workers (entry points), Application Use Cases, Domain Model, Infrastructure Adapters, and Database / External APIs. White background, navy text, muted gold layer boundaries, subtle green stability markers between layers.
Long-lived Symfony application layers: thin entry points, coordinating use cases, a stable domain model, infrastructure adapters, and the external world.

Tests Are A Maintenance Tool, Not A Checkbox

Tests are not only for proving new features work. In enterprise systems, tests protect old behavior from enthusiastic future changes.

You need different kinds.

  1. Unit tests protect domain rules — They should be fast and focused.
  2. Integration tests protect wiring — They confirm services, database, Messenger, and infrastructure behave together.
  3. Functional tests protect HTTP behavior — They catch routing, security, serialization, and response contract issues.
  4. Contract tests protect integrations — They prevent silent API drift between systems.
  5. Migration tests protect data changes — They catch assumptions before production data does.

A test suite is like a bridge inspection schedule. You do not wait for collapse to check the bolts.

Design Backward-Compatible Change Paths

Enterprise systems rarely let you deploy database, backend, frontend, workers, and integrations at the exact same millisecond.

That means changes need compatibility windows.

For database changes, use expand-and-contract.

  1. Add new nullable column or table — Do not break old code.
  2. Deploy code that writes both old and new shapes — Keep compatibility.
  3. Backfill historical data — Move old data safely.
  4. Switch reads to the new shape — Verify behavior.
  5. Remove old shape later — Clean up when confidence is real.

This is slower than "just rename the column," but production likes boring plans.

Version APIs And Database Schemas Together

In long-lived systems, the API and the database evolve at different speeds, but they share consumers. A breaking change to either can be felt for months.

Two habits that keep this manageable:

Treat the API as a versioned product. Expose /v1, /v2 as parallel routes when you make breaking changes, keep both running for a deprecation window (six months minimum for most enterprises), and publish the deprecation timeline somewhere clients actually read. The OpenAPI spec is your contract — diff it on every PR and require a version bump for any field removed or type changed.

PHP routes/api.yaml
controllers_v1:
    resource: '../src/Controller/Api/V1/'
    type: attribute
    prefix: /v1

controllers_v2:
    resource: '../src/Controller/Api/V2/'
    type: attribute
    prefix: /v2

Treat schema migrations as part of the contract. A migration that drops a column from the same release that removes a writer is one deploy away from data loss. The expand-and-contract pattern (above) takes longer but lets you split the change across two releases:

Release API Database
Sprint 1 name and display_name both writable Add display_name column, nullable
Sprint 2 API writes only display_name, reads from both Backfill display_name from name
Sprint 3 API documents name as deprecated Stop writing to name
Sprint 4 (later) Bump to /v2, remove name from API Drop name column

That looks like a lot of work for one rename. It is. The alternative is a coordinated deploy across mobile clients, partner integrations, the web app, and the database — at exactly the same minute, on a Friday, with no rollback if anything is wrong.

The discipline is boring. The lack of incidents is not.

Observability Keeps Old Systems Understandable

A long-lived app needs logs, metrics, traces, health checks, queue monitoring, and clear error reporting.

Otherwise, every incident becomes a detective novel written by five teams over seven years.

PHP src/Application/ApproveRefund.php
$this->logger->info('Refund approved', [
    'refund_id' => $refund->id(),
    'approved_by' => $command->approvedBy,
]);

Good logs include business identifiers. Bad logs say "Something failed." Super helpful, thanks.

Operational Runbooks Are Architecture

Most enterprise PHP teams document their code reasonably well and their operations hardly at all. Then production breaks at 2am and the on-call engineer is reading PRs from 2022 to figure out how a queue worker is supposed to be restarted.

A runbook is a short document that answers operational questions for a single subsystem:

  1. What does this subsystem do, in one paragraph?
  2. How do I tell it's healthy? (which dashboard, which metric, which log query)
  3. What does it depend on? (database, queue, third-party API)
  4. How do I restart it cleanly? (which command, which order, who to notify)
  5. What are the known failure modes and the mitigations?

Keep them in the repository, near the code:

Text
docs/runbooks/
  payments.md
  messenger-workers.md
  search-reindex.md
  email-deliverability.md

A payments.md that explains what happens when Stripe returns a 503, who decides to retry, and how to verify reconciliation is more valuable than ten architecture decision records nobody reads.

A trick that earns trust: when your team handles an incident, the postmortem outcome is "update the runbook." If the runbook would have prevented the incident, fix it. If the runbook didn't exist, write it. Over a year, you accumulate institutional memory that survives team turnover — and that's exactly the asset enterprise systems are built around.

Documentation Should Live Near The Code

Enterprise documentation does not need to be perfect. It needs to be close enough to reality that a developer can trust it.

Keep architectural decisions, setup notes, integration contracts, queue behavior, and operational runbooks near the repository when possible.

A short docs/payments.md that explains retry behavior is more useful than a beautiful Confluence page nobody has updated since 2021.

Common Enterprise Symfony Problems

  1. Letting controllers own business logic — This makes reuse and testing harder over time.
  2. Ignoring deprecations until major upgrades — The upgrade becomes a rescue mission.
  3. Coupling directly to vendors — Payment, email, search, and CRM vendors change more often than your domain.
  4. Skipping contract tests — Integrations fail quietly until customers find the problem.
  5. Treating old code as bad code — Some old code encodes real business knowledge. Respect it before changing it.

Longevity is not glamorous. It is disciplined.

Pro Tips

  1. Create explicit application services — They give your system a stable use-case layer.
  2. Use interfaces at volatile boundaries — Vendors, external APIs, storage, and messaging deserve seams.
  3. Upgrade continuously — Small upgrades beat heroic upgrades.
  4. Monitor queues and workers — Long-lived systems often fail asynchronously before they fail loudly.
  5. Write migration playbooks — Data changes need plans, not hope.

Final Tips

The hardest enterprise systems I've worked with were not hard because of PHP. They were hard because nobody knew which behavior was intentional, which was historical, and which was accidental.

Symfony gives you the tools to make that distinction clearer: contracts, services, components, events, tests, and predictable upgrades.

Build for the developer who joins three years from now. That developer might be you. Good luck with the long game 👊