All articles
  • Symfony
  • PHP
  • Quality

5 practices for a maintainable Symfony back-end

Thin controllers, validated DTOs, async processing, clean configuration and tests — five habits that keep a Symfony project pleasant to evolve.

A Symfony project always starts well. It's a few months later, as features pile up, that you find out whether the code holds up. Here are five habits that make a difference day to day.

1. Thin controllers, logic in services

A controller should only receive the request, call a service and return a response. Business logic belongs in services, injected automatically thanks to autowiring:

PHP
#[Route('/orders/{id}/cancel', methods: ['POST'])]
public function cancel(Order $order, OrderCanceller $canceller): Response
{
    $canceller->cancel($order);

    return $this->redirectToRoute('order_show', ['id' => $order->getId()]);
}

The result: the logic can be tested on its own and reused elsewhere, from a console command or a message handler for instance.

2. Validated DTOs for incoming data

Instead of reading the request array field by field, describe the expected data in an object, along with its validation rules. Since Symfony 6.3, the MapRequestPayload attribute fills and validates that object automatically:

PHP
final class CreateContactRequest
{
    public function __construct(
        #[Assert\NotBlank] public readonly string $name,
        #[Assert\Email] public readonly string $email,
        #[Assert\Length(min: 10)] public readonly string $message,
    ) {}
}

#[Route('/api/contact', methods: ['POST'])]
public function contact(#[MapRequestPayload] CreateContactRequest $request): JsonResponse
{
    // $request is already valid: on error, Symfony returns a 422
}

3. Move slow work out of the request

Sending an email, generating a PDF or calling an external API shouldn't keep the user waiting. With Messenger, you dispatch a message and a worker handles it in the background:

PHP
$bus->dispatch(new SendWelcomeEmail($user->getId()));
YAML
# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            async: '%env(MESSENGER_TRANSPORT_DSN)%'
        routing:
            App\Message\SendWelcomeEmail: async

Pass the identifier rather than the whole entity: the worker reloads fresh data from the database.

4. Clean configuration, no secrets in the code

URLs, API keys and settings that change per environment go through environment variables, injected with #[Autowire]:

PHP
public function __construct(
    #[Autowire(env: 'PAYMENT_API_URL')] private readonly string $paymentApiUrl,
) {}

For production secrets, the built-in vault (bin/console secrets:set) keeps them out of plain files.

5. Tests and static analysis

You don't need 100% coverage to feel confident. Unit tests on business services, a few functional tests with WebTestCase on critical paths, and PHPStan in continuous integration already catch most regressions:

PHP
public function testCancelledOrderCannotBeCancelledTwice(): void
{
    $order = OrderFactory::cancelled();

    $this->expectException(OrderAlreadyCancelled::class);
    (new OrderCanceller())->cancel($order);
}

In short

None of these practices is spectacular, and that's exactly their strength: applied from the start, they keep a project readable, testable and easy to pick up for the next developer — who is often yourself, six months later.