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
## โโ 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
#[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
/**
* 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
{% 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>
<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
<?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)
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
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
/**
* 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
<?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?
Just blogged: "Is not writing tests unprofessional?" https://t.co/Vrr0a1qNQW
— Matthias Noback (@matthiasnoback) September 30, 2019
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. ๐
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 )
- Report any error/typo.
- Report something that could be improved.
- Like and retweet!
- Follow me on Twitter Follow me on Twitter
- Subscribe to the RSS feed.
- Click on the More on Stackoverflow buttons to make me win "Announcer" badges ๐ .
Thank you for reading! And see you soon on Strangebuzz! ๐
[๐ฌ๐ง] 11th blog post of the year. This time we will check all the official Symfony Best Practices! https://t.co/hRavusdTKi Proofreading, comments, likes and retweets are welcome! ๐ Annual goal: 11/12 (91%) #php #symfony #strangebuzz #blog #blogging #bestpractices โ๏ธ ๐ ๐
— [SB] COil (@C0il) December 27, 2019