What are your Symfony best practices?

Published on 2019-12-22 • Modified on 2021-12-24

In this post, we will check all the Symfony best practices listed in the official documentation. For each one, I will say if I agree with it or not and why. Let's go! ๐Ÿ˜Ž

» Published in "A Week of Symfony #678" (23-29 December 2019).

[Edit 2021-12-24]: Update for Symfony 5.4

Introduction

"Best practices" are essential. They are rules you can follow (or not) to make a codebase of a project consistent and homogeneous. This is very important when working as a team (and it's almost always the case) in a professional context. However, that's not the case for this blog! So, let's check each rule of the official documentation. If I totally agree I will place a . If I find it OK but I don't use it in every case, I will put a ๏ธ and eventually, if I disagree I will put a . I'll add some comments to justify my choices.

But what makes a practice a "best practice"?

Nothing! A practice is considered a "best practice" only if you think it is! Remember that you shouldn't follow these rules blindly. Always be pragmatic, test stuff, try things, make your own experience and build your own set of best practices. For each section, I'll show a code snippet extracted from this website.

Creating the Project

Best practice My opinion Notes
Use the Symfony Binary to Create Symfony Applications (1.1) When running the Symfony binary, it will check if a new version of itself is available. It means that when creating a new project with it, you are sure it will always create the project for the last stable version and you are sure it will respect all best practices, especially for the directory structure. (see 1.2)
Use the Default Directory Structure (1.2) Avoid doing exotic stuff (when applying DDD you can't follow this).
## โ€”โ€” Symfony binary ๐Ÿ’ป โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”
cert-install: ## Install the local HTTPS certificates
	@$(SYMFONY_BIN) server:ca:install

serve: ## Serve the application with HTTPS support (add "--no-tls" to disable https)
	@$(SYMFONY_BIN) serve --daemon --port=$(HTTP_PORT)

unserve: ## Stop the webserver
	@$(SYMFONY_BIN) server:stop

1.1 » The Symfony CLI can also serve your application locally with https (check out my full Makefile here).

Configuration

Best practice My opinion Notes
Use Environment Variables for Infrastructure Configuration (2.1) .env files are easy to use. I prefer to have a single file to keep all parameters in one place.
Use Secret for Sensitive Information (2.2) When I wrote this article, this "best pratice" wasn't proposed yet. The secret component was introduced in Symfony 4.4 and it's now the way to go for storing securly your passwords or API tokens.
Use Parameters for Application Configuration (2.3) -
Use Short and Prefixed Parameter Names (2.4) This is something I partially use currently, but I never had a "conflict" problem between a third party bundle and the application.
Use Constants to Define Options that Rarely Change (2.5) The number of items used for the pagination is a good example. Check out the case below extracted from my SnippetContoller.
#[Route(path: '/{_locale}/blog', name: 'blog_', requirements: ['_locale' => '%locales_requirements%'])]
final class BlogController extends AbstractController
{
    // Snippets
    // - templates/blog/posts/_21.html.twig (L23->L23+2)
    // - templates/snippet/code/_35.html.twig (L23->L23+2)
    // - templates/blog/posts/_64.html.twig (L23->L23+15)
    use Post\Post13Trait;
    use Post\Post26Trait;
    use Post\Post51Trait;
    use Post\Post59Trait;
    use Post\Post90Trait;
    use Post\Post138Trait;
    use Post\Post165Trait;
    use Post\Post216Trait;
    use Post\Post254Trait;

2.5 » I am showing ten items by page in the main blog post list.

Business Logic

Best practice My opinion Notes
Don't Create any Bundle to Organize your Application Logic (3.1) I don't agree with this one. For one of the applications I work on, we had to deliver a big project concerning a specific functional domain. We decided to put all the new things concerning this project in a new bundle. By doing so, it prevents someone to do refactor or mess up with new stuff as the subdirectory of the project doesn't exist in the master branch. The final merge is also much more straightforward.
Use Autowiring to Automate the Configuration of Application Services (3.2) Autowiring is fantastic. I was sceptical at first, but when you use it, you have this WTF moments like, "How was I doing before?". It was so dull declaring each service individually. The services.yml file got huge for no benefit in most cases.
Services Should be Private Whenever Possible (3.3) In this project, I have only one public service because the Elastica persister forces a provider service to be public. Check out the ArticleProvider of the Elasticsearch tutorial.
Use the YAML Format to Configure your Own Services (3.4) Even the actual tendency is to use PHP for configurations (probably for Symfony 6). Using YAML to declare very specific stuffs in the services.yml file is straightforward.
Use Attributes or Annotations to Define the Doctrine Entity Mapping (3.5) I have never liked using a separated YAML schema for my Doctrine entities. It's better to have the properties and the related mapping in the same file, so it's easy to check and modify when developing.
/**
 * @see https://app.abstractapi.com/api/ip-geolocation/documentation
 */
final readonly class AbstractApi
{
    public function __construct(
        private HttpClientInterface $abstractApiClient,
        private string $abstractApiKey,
    ) {
    }

3.2 » A service using named parameters.

Controllers

Best practice My opinion Notes
Make your Controller Extend the AbstractController Base Controller (4.1) The abstract controller still provide the most common functions that a controller needs: render(), redirect() but prevents from accessing public services as we could do in previous Symfony versions.
Use Attributes or Annotations to Configure Routing, Caching and Security (4.2) I have worked in the past on big projects where all the routing was in YAML files. It rapidly becomes a huge mess. When using a routing annotation, you've got the route, the path and the related code at the same place, so it's easy to modify when developing.
Don't Use Annotations to Configure the Controller Template (4.3) I tested the @template annotation on several projects. It worked, but sometimes you have to search the template file that it is used. When using the render() function with PHPStorm, you can click on the template name to open the file! Once again, avoid magic. Moreover, a controller should always return a Response object.
Use Dependency Injection to Get Services (4.4) As we saw in 4.1, we mustn't directly use public services, but it's better to use dependency injection in constructor or method parameters.
Use ParamConverters If They Are Convenient (4.5) I rarely used this. It also introduces some magic we want to avoid. I prefer having all the workflow in the controller and raise the right exceptions according to the request.

/**
 * App generic actions and locales root handling.
 */
final class AppController extends AbstractController
{
    public function __construct(
        public readonly ArticleRepository $articleRepository,
    ) {
    }

    #[Route(path: '/', name: 'root')]

4.1 » My home controller, extending the Symfony AbstractController.

Templates

Best practice My opinion Notes
Use Snake Case for Template Names and Variables (5.1) Why this rule? Because we have to choose one! ๐Ÿ
Prefix Template Fragments with an Underscore (5.2) This is a rule I especially like. It comes from symfony1 โ„ข where such templates (It was pure PHP at the time) were called "partials". Using this naming strategy allows us to quickly differentiate the main templates that are bound to a controller and the others who can be included and used by the main ones.
{% trans_default_domain 'search' %}

{% set route = route is defined ? route : 'search_main' %}

<div class="row">
    <div class="col-lg-6 col-md-6 col-sm-8 ml-auto mr-auto">
        <div class="card">
            <div class="card-body">
                <form action="{{ path(route) }}" method="get">
                    <div class="form-group">
                        <label for="post-q">{{ 'keyword'|trans({}, 'search') }}</label>
                        {% include 'search/_autocomplete.html.twig' with {route: 'search_main'} %}
                    </div>

                    <div class="card-footer justify-content-center">
                        <button type="submit" class="btn btn-primary"><i class="fab fa-searchengin"></i> {{ 'search'|trans }}</button>&nbsp;&nbsp;
                        <button type="reset" class="btn"><i class="fad fa-minus-octagon"></i> {{ 'reset'|trans }}</button>
                    </div>
                </form>
            </div>
        </div>
    </div>
</div>

5.2,6.2 » My _form.html.twig partial that is included in both my main search template and the blog posts of the Elasticsearch tutorial.

Forms

Best practice My opinion Notes
Define your Forms as PHP Classes (6.1) A form created on the fly in a controller can't be reused. Don't do it.
Add Form Buttons in Templates (6.2) Generally, I declare the submit buttons in the form type if they are several so it's easier to check which one was clicked ($form->get('saveAndAdd')->isClicked()). Otherwise, I put it in the template directly.
Define Validation Constraints on the Underlying Object (6.3) -
Use a Single Action to Render and Process the Form (6.4) Avoid duplicating code! If you submit to another action, you will have to prepare the data for the view in both methods. This something you should avoid even if you could have a service gathering the data you need. Keep the code simple!
<?php

declare(strict_types=1);

// src/Form/NewsletterType.php

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\NotBlank;

/**
 * Newsletter subscribe form with honeypot.
 */
final class NewsletterType extends AbstractType
{
    public const HONEYPOT_FIELD_NAME = 'email';
    public const EMAIL_FIELD_NAME = 'information';

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add(self::EMAIL_FIELD_NAME, EmailType::class, [
            'required' => true,
            'constraints' => [
                new NotBlank(),
                new Email(['mode' => 'strict']),
            ],
        ]);
        $builder->add(self::HONEYPOT_FIELD_NAME, TextType::class, ['required' => false]);
        $builder->setMethod(Request::METHOD_POST);
    }
}

6.1 » This is a fake newsletter form I am using in the post "Implementing a honeypot in a Symfony form".

Internationalization (i18n)

Best practice My opinion Notes
Use the XLIFF Format for Your Translation Files (7.1) I find the YAML files much easier to use. As I don't use a "professional" tool for translations, that's enough for my needs.
Use Keys for Translations Instead of Content Strings (7.2) Using a "label" as the reference is just a pain and prone errors. Don't do this! โ›”
h2_7: Internationalization (i18n)

rule_7_1: Use the XLIFF Format for Your Translation Files
rule_7_1_notes: >
  I find the YAML files much easier to use. As I don't use a "professional" tool
  for translations, that's enough for my needs.

rule_7_2: Use Keys for Translations Instead of Content Strings
rule_7_2_notes: >
  Using a "label" as the reference is just a pain and prone errors. Don't do this! โ›”

p7: The i18n blocks I am using to translate the seventh section of this blog post. ๐Ÿ™‚
rule_7_1: Utilisez le format XLIFF pour vos fichiers de traduction
rule_7_1_notes: >
  Je trouve le format YAML beaucoup plus facile ร  utiliser. Comme je n'utilise
  pas d'outil de traduction professionnel, c'est suffisant pour mes besoins.

rule_7_2: Utilisez des clรฉs pour les traductions au lieu de chaรฎnes
rule_7_2_notes: >
  Absolument ! Utiliser une chaรฎne comme rรฉfรฉrence est juste laborieux et source
  d'erreurs. Ne faites pas รงa ! โ›”

p7: Les blocs i18n que j'utilise pour traduire la section sept de cet article ๐Ÿ™‚.

7.2 » The i18n blocks I am using to translate the seventh section of this blog post. ๐Ÿ™‚

Security

Best practice My opinion Notes
Define a Single Firewall (8.1) -
Use the auto Password Hasher (8.2) -
Use Voters to Implement Fine-grained Security Restrictions (8.3) -
security:
  
    password_hashers:
        App\Entity\User:
            algorithm: auto

    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email

8.3) hashing algorithm uses the "auto" mode. 8.x) The enable_authenticator_manager: true parameter indicates the use of the new security system that was introduced in Symfony 5.1.

Please, never commit passwords nor API tokens!

Web Assets

Best practice My opinion Notes
Use Webpack Encore to Process Web Assets (9.1) [Edit 2020-04-20] That's it. I am now handling all my assets with Webpack, it's nice. I have migrated all the JavaScript code to Vue.js mixins, the code is checked with eslint, this is much cleaner than what I used to do when having isolated <script> tags in the HTML code.
If you start a project from scratch, you should use Webpack Encore and make things the right way from the beginning. The more you wait to do it, the harder it will become.
/**
 * I am using a JavaScript module to isolate the code of each snippet.
 * In fact it's a Vue.js mixin. Take the code called by the mounted()
 * or the snippetXX() function.
 */
export default {
  data: {
    snippetFeedback: ''
  },
  methods: {
    snippet28: function () {
      if (!this.$refs.myForm.checkValidity()) {
        this.$refs.myFormSubmit.click()
        this.snippetFeedback = 'Form is NOT valid. Enter a value.'
      } else {
        this.snippetFeedback = 'Form is valid.'
      }
      // That's it! ๐Ÿ˜
    }
  }
}

The Vue.js mixin for the "Check the validity of a form before its submission" snippet. Each JavaScript snippet is now isolated in its own module.

Tests

Best practice My opinion Notes
Smoke Test your URLs (10.1) These tests are so easy to write! It would be unprofessional not to write them. (I think I saw a tweet about this ๐Ÿ˜)
Hardcode URLs in a Functional Test (10.2) This rule might look weird for beginners as one of the first things we learn when developing with Symfony is: "Never hardcode an URL in your templates!". But here, we want to decouple the code from the tests.
<?php

declare(strict_types=1);

namespace App\Tests\Functional\Controller;

use App\Subscriber\NotFoundExceptionSubscriber;
use App\Tests\WebTestCase;
use Symfony\Component\ErrorHandler\ErrorHandler;
use Symfony\Component\HttpFoundation\Response;

final class AppControllerTest extends WebTestCase
{
    /**
     * @see AppController::root
     */
    public function testRootAction(): void
    {
        $client = self::createClient();
        $this->assertUrlIsRedirectedTo($client, '/', '/en');
    }

    /**
     * @see AppController::homepage
     */
    public function testHomepage(): void
    {
        $client = self::createClient();
        $this->assertResponseIsOk($client, '/en');
        $this->assertResponseIsOk($client, '/fr');
    }

    /**
     * @see NotFoundExceptionSubscriber
     */
    public function testNotFoundExceptionSubscriber(): void
    {
        $client = self::createClient();
        $client->request('GET', '/404');
        self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
        self::assertSelectorTextContains('body', '404 found');
    }

    /**
     * @see ErrorHandler::handleException
     */
    public function test404(): void
    {
        $client = self::createClient();
        $client->request('GET', '/not-found');
        self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
    }
}

In these functional tests, I check a redirection and that we have a 404 response code when accessing a path unknown to the application (and that the custom 404 template doesn't raise a 500 error).

» Check out my "Organizing your Symfony project tests" blog post.

Is not writing tests unprofessional?

Conclusion

As you see, I don't follow every official BP but most of them. I think everyone will have a different point of view. It's, of course, nice to follow all these rules. But don't take this as a dogma, think about it more as guidelines. Not following any could be problematic as someone arriving on your project could feel lost as they wouldn't see stuff they used to see in other Symfony projects. If you don't agree with me, please let me know on Twitter or Slack, and try to change my mind.
What about you? What are your Symfony best practices? ๐Ÿค”

That's it! I hope you like it. Check out the links below to have additional information related to the post. As always, feedback, likes and retweets are welcome. (see the box below) See you! COil. ๐Ÿ˜Š

  Read the doc  More on the web  More on Stackoverflow

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.

I put this license in this post as I have extracted the rules from the official Symfony documentation.

They gave feedback and helped me to fix errors and typos in this article; many thanks to jmsche, danabrey, keversc. ๐Ÿ‘

  Work with me!


Call to action

Did you like this post? You can help me back in several ways: (use the Tweet on the right to comment or to contact me )

Thank you for reading! And see you soon on Strangebuzz! ๐Ÿ˜‰

COil