Symfony5 The Fast Track
Symfony5 The Fast Track
Fabien Potencier
https://fabien.potencier.org/
@fabpot
@fabpot
Symfony 5: The Fast Track
ISBN-13: 978-2-918390-37-4
Symfony SAS
92-98, boulevard Victor Hugo
92 110 Clichy
France
This work is licensed under the Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0)
license (https://creativecommons.org/licenses/by-nc-sa/4.0/).
Below is a human-readable summary of (and not a substitute for) the license (https://creativecommons.org/
licenses/by-nc-sa/4.0/legalcode).
You are free to
Share — copy and redistribute the material in any medium or format
Adapt — remix, transform, and build upon the material
• Attribution: You must give appropriate credit, provide a link to the license, and indicate if changes were
made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses
you or your use.
• Non Commercial: You may not use the material for commercial purposes.
• Share Alike: If you remix, transform, or build upon the material, you must distribute your contributions
under the same license as the original.
The information in this book is distributed on an “as is” basis, without warranty. Although every precaution
has been taken in the preparation of this work, neither the author(s) nor Symfony shall have any liability to
any person or entity with respect to any loss or damage caused or alleged to be caused directly or indirectly by
the information contained in this work.
If you find typos or errors, feel free to report them at [email protected]. This book is continuously
updated based on user feedback.
Locale en
v
Step 27: Building an SPA............................................................................... 283
Step 28: Localizing an Application ................................................................. 301
Step 29: Managing Performance .................................................................... 315
Step 30: Discovering Symfony Internals.......................................................... 325
Step 31: Using Redis to Store Sessions ............................................................ 335
Step 32: Using RabbitMQ as a Message Broker............................................... 339
Step 33: What’s Next? ................................................................................... 345
vi
Table of Contents
Step 0: What is it about?.................................................................................. 25
ix
3.6: Going to Production ............................................................................. 46
x
8.5: Linking Entities .................................................................................... 81
8.6: Adding more Properties......................................................................... 85
8.7: Migrating the Database ........................................................................ 85
8.8: Updating the Local Database ................................................................ 86
8.9: Updating the Production Database ........................................................ 86
xi
13.1: Defining Lifecycle Callbacks.............................................................. 127
13.2: Adding Slugs to Conferences .............................................................. 128
13.3: Generating Slugs .............................................................................. 130
13.4: Defining a Complex Lifecycle Callback .............................................. 131
13.5: Configuring a Service in the Container ............................................... 132
13.6: Using Slugs in the Application ........................................................... 134
xii
Step 17: Testing ............................................................................................ 169
17.1: Writing Unit Tests ............................................................................ 169
17.2: Writing Functional Tests for Controllers ............................................ 171
17.3: Configuring the Test Environment ..................................................... 173
17.4: Working with a Test Database .......................................................... 173
17.5: Defining Fixtures.............................................................................. 174
17.6: Loading Fixtures .............................................................................. 176
17.7: Crawling a Website in Functional Tests.............................................. 177
17.8: Submitting a Form in a Functional Test.............................................. 178
17.9: Reloading the Fixtures ...................................................................... 180
17.10: Automating your Workflow with a Makefile ..................................... 180
17.11: Resetting the Database after each Test ............................................. 181
17.12: Using a real Browser for Functional Tests......................................... 183
17.13: Running Black Box Functional Tests with Blackfire........................... 184
xiii
20.4: Generating Absolute URLs in a Symfony Command ........................... 213
20.5: Wiring a Route to a Controller .......................................................... 214
20.6: Using a Mail Catcher ........................................................................ 216
20.7: Accessing the Webmail...................................................................... 217
20.8: Managing Long-Running Scripts........................................................ 219
20.9: Sending Emails Asynchronously......................................................... 219
20.10: Testing Emails ................................................................................ 220
20.11: Sending Emails on SymfonyCloud .................................................... 220
xiv
24.3: Creating a CLI Command ................................................................. 253
24.4: Setting up a Cron on SymfonyCloud................................................... 255
xv
28.8: Updating Functional Tests................................................................. 313
xvi
Acknowledgments
xvii
Translators
The official Symfony documentation is only available in English. We had
some translations in the past but we decided to stop providing them as
they were always out of sync. And outdated documentation is probably
worse than no documentation at all.
The main issue with translations is maintenance. The Symfony
documentation is updated every single day by dozens of contributors.
Having a team of volunteers translating all changes in near real time is
almost impossible.
However, translating a book like the one you are currently reading is
more manageable as I tried to write about features that won’t change
much over time. This is why the book contents should stay quite stable
over time.
But why would we ever want non-English documentation in a tech world
where English is the de facto default language? Symfony is used by
developers everywhere in the world. And some of them are less
comfortable reading English material. Translating some “getting started”
documentation is part of the Symfony diversity initiative in which we
strive to find ways to make Symfony as inclusive as possible.
As you can imagine, translating more than 300 pages is a huge amount
of work, and I want to thank all the people who helped translating this
book.
xviii
Company Backers
This book has been backed by people around the world who helped this
project financially. Thanks to them, this content is available online for
free and available as a paper book during Symfony conferences.
https://packagist.com/
https://darkmira.io/ https://blackfire.io/
https://basecom.de/ https://dats.team/
https://sensiolabs.com/ https://les-tilleuls.coop/
https://redant.nl/ https://www.akeneo.com/
https://www.facile.it/ https://izi-by-edf.fr/
https://www.musement.com/ https://setono.com/
xix
Individual Backers
Javier Eguiluz @javiereguiluz
Tugdual Saunier @tucksaun
Alexandre Salomé https://alexandre.salome.fr
Timo Bakx @TimoBakx
Arkadius Stefanski https://ar.kadi.us
Oskar Stark @OskarStark
slaubi
Jérémy Romey @jeremyFreeAgent
Nicolas Scolari
Guys & Gals at
https://symfonycasts.com
SymfonyCasts
Roberto santana @robertosanval
Ismael Ambrosi @iambrosi
Mathias STRASSER https://roukmoute.github.io/
Platform.sh team http://www.platform.sh
ongoing https://www.ongoing.ch
Magnus Nordlander @magnusnordlander
Nicolas Séverin @nico-incubiq
Centarro https://www.centarro.io
Lior Chamla https://learn.web-develop.me
Art Hundiak @ahundiak
Manuel de Ruiter https://www.optiwise.nl/
Vincent Huck
Jérôme Nadaud https://nadaud.io
Michael Piecko @mpiecko
Tobias Schilling https://tschilling.dev
ACSEO https://www.acseo.fr
Omines Internetbureau https://www.omines.nl/
Seamus Byrne http://seamusbyrne.com
Pavel Dubinin @geekdevs
xx
Jean-Jacques PERUZZI https://linkedin.com/in/jjperuzzi
Alexandre Jardin @ajardin
Christian Ducrot http://ducrot.de
Alexandre HUON @Aleksanthaar
François Pluchino @francoispluchino
We Are Builders https://we.are.builders
Rector @rectorphp
Ilyas Salikhov @salikhov
Romaric Drigon @romaricdrigon
Lukáš Moravec @morki
Malik Meyer-Heder @mehlichmeyer
Amrouche Hamza @cDaed
Russell Flynn https://custard.no
Shrihari Pandit @shriharipandit
Salma NK. @os_rescue
Nicolas Grekas
Roman Ihoshyn https://ihoshyn.com
Radu Topala https://www.trisoft.ro
Andrey Reinwald https://www.facebook.com/andreinwald
JoliCode @JoliCode
Rokas Mikalkėnas
Zeljko Mitic @strictify
Wojciech Kania @wkania
Andrea Cristaudo https://andrea.cristaudo.eu/
Adrien BRAULT-
@AdrienBrault
LESAGE
Cristoforo Stevio
http://www.steviostudio.it
Cervino
Michele Sangalli
Florian Reiner http://florianreiner.com
Ion Bazan @IonBazan
Marisa Clardy @MarisaCodes
xxi
Donatas Lomsargis http://donatas.dev
Johnny Lattouf @johnnylattouf
Duilio Palacios https://styde.net
Pierre Grimaud @pgrimaud
Marcos Labad Díaz @esmiz
Stephan Huber https://www.factorial.io
Loïc Vernet https://www.strangebuzz.com
Daniel Knoch http://www.cariba.de
Emagma http://www.emagma.fr
Gilles Doge
Malte Wunsch @MalteWunsch
Jose Maria Valera
@Chemaclass
Reales
Cleverway https://cleverway.eu/
Nathan @nutama
Abdellah EL https://connect.symfony.com/profile/
GHAILANI aelghailani
Solucionex https://www.solucionex.com
Elnéris Dang https://linkedin.com/in/elneris-dang/
Class Central https://www.classcentral.com/
Ike Borup https://idaho.dev/
Christoph Lühr https://www.christoph-luehr.com/
Zig Websoftware http://www.zig.nl
Dénes Fakan @DenesFakan
Danny van Kooten http://dvk.co
Denis Azarov http://azarov.de
Martin Poirier T. https://linkedin.com/in/mpoiriert/
Dmytro Feshchenko @dmytrof
Carl Casbolt https://www.platinumtechsolutions.co.uk/
Irontec https://www.irontec.com
Lukas Plümper https://lukaspluemper.de/
Neil Nand https://neilnand.co.uk
xxii
Andreas Möller https://localheinz.com
Alexey Buldyk https://buldyk.pw
Page Carbajal https://pagecarbajal.com
Florian Voit https://rootsh3ll.de
Webmozarts GmbH https://webmozarts.com
Alexander M. Turek @derrabus
Zan Baldwin @ZanBaldwin
Ben Marks, Magento http://bhmarks.com
xxiii
Family Love
Family support is everything. A big thank-you to my wife, Hélène and
my two wonderful children, Thomas and Lucas, for their continuous
support.
Enjoy Thomas’s illustration… and the book!
xxiv
Step 0
What is it about?
25
staggering. When working full-time on a project, developers do not have
time to follow everything happening in the community. I know first hand
as I would not pretend that I can follow everything myself. Far from it.
And it is not just about new ways of doing things. It is also about
new components: HTTP client, Mailer, Workflow, Messenger. They are
game changers. They should change the way you think about a Symfony
application.
I also feel the need for a new book as the Web has evolved a lot. Topics
like APIs, SPAs, containerization, Continuous Deployment, and many
others should be discussed now.
Your time is precious. Don’t expect long paragraphs, nor long
explanations about core concepts. The book is more about the journey.
Where to start. Which code to write. When. How. I will try to generate
some interest on important topics and let you decide if you want to learn
more and dig further.
I don’t want to replicate the existing documentation either. Its quality
is excellent. I will reference the documentation copiously in the “Going
Further” section at the end of each step/chapter. Consider this book as a
list of pointers to more resources.
The book describes the creation of an application, from scratch to
production. We won’t develop everything to make it production ready
though. The result won’t be perfect. We will take shortcuts. We might
even skip some edge-case handling, validation or tests. Best practices
won’t be respected all the time. But we are going to touch on almost every
aspect of a modern Symfony project.
While starting to work on this book, the very first thing I did was code
the final application. I was impressed with the result and the velocity I
was able to sustain while adding features, with very little effort. That’s
thanks to the documentation and the fact that Symfony 5 knows how to
get out of your way. I am sure that Symfony can still be improved in many
ways (and I have taken some notes about possible improvements), but the
developer experience is way better than a few years ago. I want to tell the
world about it.
The book is divided into steps. Each step is sub-divided into sub-steps.
They should be fast to read. But more importantly, I invite you to code as
26
you read. Write the code, test it, deploy it, tweak it.
Last, but not least, don’t hesitate to ask for help if you get stuck. You
might hit an edge case or a typo in the code you wrote might be difficult
to find and fix. Ask questions. We have a wonderful community on Slack
and Stack Overflow.
Ready to code? Enjoy!
27
Step 1
Checking your Work
Environment
Before starting to work on the project, we need to check that everyone has
a good working environment. It is very important. The developers tools
we have at our disposal today are very different from the ones we had 10
years ago. They have evolved a lot, for the better. It would be a shame to
not leverage them. Good tools can get you a long way.
Please, don’t skip this step. Or at least, read the last section about the
Symfony CLI.
1.1 A Computer
You need a computer. The good news is that it can run on any popular
OS: macOS, Windows, or Linux. Symfony and all the tools we are going
to use are compatible with each of these.
29
1.2 Opinionated Choices
I want to move fast with the best options out there. I made opinionated
choices for this book.
PostgreSQL is going to be our choice for everything: from database to
queues, from cache to session storage. For most projects, PostgreSQL is
the best solution, scale well, and allows to simplify the infrastructure with
only one service to manage.
At the end of the book, we will learn how to use RabbitMQ for queues
and Redis for sessions.
1.3 IDE
You can use Notepad if you want to. I would not recommend it though.
I used to work with Textmate. Not anymore. The comfort of using
a “real” IDE is priceless. Auto-completion, use statements added and
sorted automatically, jumping from one file to another are a few features
that will boost your productivity.
I would recommend using Visual Studio Code or PhpStorm. The former is
free, the latter is not but has a better integration with Symfony (thanks to
the Symfony Support Plugin). It is up to you. I know you want to know
which IDE I am using. I am writing this book in Visual Studio Code.
1.4 Terminal
We will switch from the IDE to the command line all the time. You can
use your IDE’s built-in terminal, but I prefer to use a real one to have
more space.
Linux comes built-in with Terminal. Use iTerm2 on macOS. On
Windows, Hyper works well.
30
1.5 Git
My last book recommended Subversion for version control. It looks like
everybody is using Git now.
On Windows, install Git bash.
Be sure you know how to do the common operations like running git
clone, git log, git show, git diff, git checkout, …
1.6 PHP
We will use Docker for services, but I like to have PHP installed on my
local computer for performance, stability, and simplicity reasons. Call me
old school if you like, but the combination of a local PHP and Docker
services is the perfect combo for me.
Use PHP 8.0 and check that the following PHP extensions are installed
or install them now: intl, pdo_pgsql, xsl, amqp, gd, openssl, sodium.
Optionally install redis, curl, and zip as well.
You can check the extensions currently enabled via php -m.
We also need php-fpm if your platform supports it, php-cgi works as well.
1.7 Composer
Managing dependencies is everything nowadays with a Symfony project.
Get the latest version of Composer, the package management tool for
PHP.
If you are not familiar with Composer, take some time to read about it.
You don’t need to type the full command names: composer req does
the same as composer require, use composer rem instead of composer
remove, …
31
1.8 NodeJS
We won’t write much JavaScript code, but we will use JavaScript/NodeJS
tools to manage our assets. Check that you have the NodeJS installed and
the Yarn package manager.
Check that your computer has all needed requirements by running the
following command:
$ symfony book:check-requirements
If you want to get fancy, you can also run the Symfony proxy. It is optional
but it allows you to get a local domain name ending with .wip for your
project.
32
When executing a command in a terminal, we will almost always prefix it
with symfony like in symfony composer instead of just composer, or symfony
console instead of ./bin/console.
The main reason is that the Symfony CLI automatically sets some
environment variables based on the services running on your machine
via Docker. These environment variables are available for HTTP requests
because the local web server injects them automatically. So, using symfony
on the CLI ensures that you have the same behavior across the board.
Moreover, the Symfony CLI automatically selects the “best” possible PHP
version for the project.
33
Step 2
Introducing the Project
35
with an HTML frontend, an API, and an SPA for mobile phones. How
does that sound?
36
ENTRY POINTS
Apache Cordova
DEVELOPMENT PRODUCTION
SERVICES SERVICES
Notifier Notifier
Mailcatcher RabbitMQ UI RabbitMQ UI File storage Mailer
38
about developing a website. As each chapter depends on the previous
ones, a change might have consequences in all following chapters.
The good news is that the Git repository for this book is automatically
generated from the book content. You read that right. I like to automate
everything, so there is a script whose job is to read the book and create
the Git repository. There is a nice side-effect: when updating the book,
the script will fail if the changes are inconsistent or if I forget to update
some instructions. That’s BDD, Book Driven Development!
Like for cloning the repository, we are not using git checkout but symfony
book:checkout. The command ensures that whatever the state you are
currently in, you end up with a functional website for the step you ask for.
Be warned that all data, code, and containers are removed by this
operation.
You can also check out any substep:
$ symfony book:checkout 10.2
Again, I highly recommend you code yourself. But if you get stuck, you
can always compare what you have with the content of the book.
Not sure that you got everything right in substep 10.2? Get the diff:
$ git diff step-10-1...step-10-2
39
# And for the very first substep of a step:
$ git diff step-9...step-10-1
You can also browse diffs, tags, and commits directly on GitHub. This is
a great way to copy/paste code if you are reading a paper book!
40
Step 3
Going from Zero to Production
I like to go fast. I want our little project to be live as fast as possible. Like
now. In production. As we haven’t developed anything yet, we will start
by deploying a nice and simple “Under construction” page. You will love
it!
Spend some time trying to find the ideal, old fashioned, and animated
“Under construction” GIF on the Internet. Here is the one I’m going to
use:
41
3.1 Initializing the Project
Create a new Symfony project with the symfony CLI tool we have
previously installed together:
$ symfony new guestbook --version=5.2
$ cd guestbook
This command is a thin wrapper on top of Composer that eases the creation
of Symfony projects. It uses a project skeleton that includes the bare
minimum dependencies; the Symfony components that are needed for
almost any project: a console tool and the HTTP abstraction needed to
create Web applications.
If you have a look at the GitHub repository for the skeleton, you will
notice that it is almost empty. Just a composer.json file. But the guestbook
directory is full of files. How is that even possible? The answer lies in
the symfony/flex package. Symfony Flex is a Composer plugin that hooks
into the installation process. When it detects a package for which it has a
recipe, it executes it.
The main entry point of a Symfony Recipe is a manifest file that describes
the operations that need to be done to automatically register the package
in a Symfony application. You never have to read a README file to
install a package with Symfony. Automation is a key feature of Symfony.
As Git is installed on our machine, symfony new also created a Git
repository for us and it added the very first commit.
Have a look at the directory structure:
├── bin/
├── composer.json
├── composer.lock
├── config/
├── public/
├── src/
├── symfony.lock
├── var/
└── vendor/
The bin/ directory contains the main CLI entry point: console. You will
use it all the time.
42
The config/ directory is made of a set of default and sensible
configuration files. One file per package. You will barely change them,
trusting the defaults is almost always a good idea.
The public/ directory is the web root directory, and the index.php script
is the main entry point for all dynamic HTTP resources.
The src/ directory hosts all the code you will write; that’s where you will
spend most of your time. By default, all classes under this directory use
the App PHP namespace. It is your home. Your code. Your domain logic.
Symfony has very little to say there.
The var/ directory contains caches, logs, and files generated at runtime by
the application. You can leave it alone. It is the only directory that needs
to be writable in production.
The vendor/ directory contains all packages installed by Composer,
including Symfony itself. That’s our secret weapon to be more
productive. Let’s not reinvent the wheel. You will rely on existing libraries
to do the hard work. The directory is managed by Composer. Never
touch it.
That’s all you need to know for now.
43
3.3 Launching a Local Web Server
The symfony CLI comes with a Web Server that is optimized for
development work. You won’t be surprised if I tell you that it works
nicely with Symfony. Never use it in production though.
From the project directory, start the web server in the background (-d
flag):
$ symfony server:start -d
The server started on the first available port, starting with 8000. As a
shortcut, open the website in a browser from the CLI:
$ symfony open:local
Your favorite browser should take the focus and open a new tab that
displays something similar to the following:
••• /
44
••• /images/under-construction.gif
45
$ symfony project:init
Using the generic and dangerous git add . works fine as a .gitignore
file has been generated that automatically excludes all files we don’t
want to commit.
Then, deploy:
$ symfony deploy
The code is deployed by pushing the Git repository. At the end of the
command, the project will have a specific domain name you can use to
access it.
Check that everything worked fine:
46
$ symfony open:remote
Going Further
• The Symfony Recipes Server, where you can find all the available
recipes for your Symfony applications;
• The repositories for the official Symfony recipes and for the recipes
contributed by the community, where you can submit your own
recipes;
• The Symfony Local Web Server;
• The SymfonyCloud documentation.
47
Step 4
Adopting a Methodology
Teaching is about repeating the same thing again and again. I won’t do
that. I promise. At the end of each step, you should do a little dance and
save your work. It is like Ctrl+S but for a website.
You can safely add “everything” as Symfony manages a .gitignore file for
you. And each package can add more configuration. Have a look at the
current content:
.gitignore
###> symfony/framework-bundle ###
/.env.local
/.env.local.php
49
/.env.*.local
/public/bundles/
/var/
/vendor/
###< symfony/framework-bundle ###
The funny strings are markers added by Symfony Flex so that it knows
what to remove if you decide to uninstall a dependency. I told you, all the
tedious work is done by Symfony, not you.
It could be nice to push your repository to a server somewhere. GitHub,
GitLab, or Bitbucket are good choices.
If you are deploying on SymfonyCloud, you already have a copy of the Git
repository, but you should not rely on it. It is only for deployment usage.
It is not a backup.
50
Step 5
Troubleshooting Problems
51
by the community).
To begin with, let’s add the Symfony Profiler, a time saver when you need
to find the root cause of a problem:
$ symfony composer req profiler --dev
52
APP_ENV) was automatically switched to prod.
$ export APP_ENV=dev
Using real environment variables is the preferred way to set values like
APP_ENV on production servers. But on development machines, having to
define many environment variables can be cumbersome. Instead, define
them in a .env file.
A sensible .env file was generated automatically for you when the project
was created:
.env
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=c2927f273163f7225a358e3a1bbbed8a
#TRUSTED_PROXIES=127.0.0.1,127.0.0.2
#TRUSTED_HOSTS='^localhost|example\.com$'
###< symfony/framework-bundle ###
Any package can add more environment variables to this file thanks to
their recipe used by Symfony Flex.
The .env file is committed to the repository and describes the default
values from production. You can override these values by creating a
.env.local file. This file should not be committed and that’s why the
.gitignore file is already ignoring it.
Never store secret or sensitive values in these files. We will see how to
manage secrets in another step.
53
projects. Let’s add more tools to help us investigate issues in
development, but also in production:
$ symfony composer req logger
••• /
The first thing you might notice is the 404 in red. Remember that this
page is a placeholder as we have not defined a homepage yet. Even if the
default page that welcomes you is beautiful, it is still an error page. So
54
the correct HTTP status code is 404, not 200. Thanks to the web debug
toolbar, you have the information right away.
If you click on the small exclamation point, you get the “real” exception
message as part of the logs in the Symfony profiler. If you want to see the
stack trace, click on the “Exception” link on the left menu.
Whenever there is an issue with your code, you will see an exception page
like the following that gives you everything you need to understand the
issue and where it comes from:
••• //
Take some time to explore the information inside the Symfony profiler by
clicking around.
Logs are also quite useful in debugging sessions. Symfony has a
convenient command to tail all the logs (from the web server, PHP, and
your application):
$ symfony server:log
55
The output is beautifully colored to get your attention on errors.
Another great debug helper is the Symfony dump() function. It is always
available and allows you to dump complex variables in a nice and
interactive format.
Temporarily change public/index.php to dump the Request object:
--- a/public/index.php
+++ b/public/index.php
@@ -18,5 +18,8 @@ if ($_SERVER['APP_DEBUG']) {
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
+
+dump($request);
+
$response->send();
$kernel->terminate($request, $response);
When refreshing the page, notice the new “target” icon in the toolbar;
it lets you inspect the dump. Click on it to access a full page where
navigating is made simpler:
••• /
Revert the changes before committing the other changes done in this step:
$ git checkout public/index.php
56
5.6 Configuring your IDE
In the development environment, when an exception is thrown, Symfony
displays a page with the exception message and its stack trace. When
displaying a file path, it adds a link that opens the file at the right line
in your favorite IDE. To benefit from this feature, you need to configure
your IDE. Symfony supports many IDEs out of the box; I’m using Visual
Studio Code for this project:
--- a/php.ini
+++ b/php.ini
@@ -6,3 +6,4 @@ max_execution_time=30
session.use_strict_mode=On
realpath_cache_ttl=3600
zend.detect_unicode=Off
+xdebug.file_link_format=vscode://file/%f:%l
Linked files are not limited to exceptions. For instance, the controller in
the web debug toolbar becomes clickable after configuring the IDE.
Don’t worry, you cannot break anything easily. Most of the filesystem is
read-only. You won’t be able to do a hot fix in production. But you will
learn a much better way later in the book.
57
Going Further
• SymfonyCasts Environments and Config Files tutorial;
• SymfonyCasts Environment Variables tutorial;
• SymfonyCasts Web Debug Toolbar and Profiler tutorial;
• Managing multiple .env files in Symfony applications.
58
Step 6
Creating a Controller
59
As the maker bundle is only useful during development, don’t forget to
add the --dev flag to avoid it being enabled in production.
The maker bundle helps you generate a lot of different classes. We will
use it all the time in this book. Each “generator” is defined in a command
and all commands are part of the make command namespace.
The Symfony Console built-in list command lists all commands
available under a given namespace; use it to discover all generators
provided by the maker bundle:
$ symfony console list make
You might wonder how you can guess the package name you need to
install for a feature? Most of the time, you don’t need to know. In many
cases, Symfony contains the package to install in its error messages.
Running symfony make:controller without the annotations package for
60
instance would have ended with an exception containing a hint about
installing the right package.
src/Controller/ConferenceController.php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
61
@@ -8,7 +8,7 @@ use Symfony\Component\Routing\Annotation\Route;
The route name will be useful when we want to reference the homepage in
the code. Instead of hard-coding the / path, we will use the route name.
Instead of the default rendered page, let’s return a simple HTML one:
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -11,8 +11,13 @@ class ConferenceController extends AbstractController
#[Route('/', name: 'homepage')]
public function index(): Response
{
- return $this->render('conference/index.html.twig', [
- 'controller_name' => 'ConferenceController',
- ]);
+ return new Response(<<<EOF
+<html>
+ <body>
+ <img src="/images/under-construction.gif" />
+ </body>
+</html>
+EOF
+ );
}
}
••• /
62
The main responsibility of a controller is to return an HTTP Response for
the request.
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
63
Notice the call to htmlspecialchars() to avoid XSS issues. This is
something that will be done automatically for us when we switch to a
proper template engine.
The {name} part of the route is a dynamic route parameter - it works like a
wildcard. You can now hit /hello then /hello/Fabien in a browser to get
the same results as before. You can get the value of the {name} parameter
by adding a controller argument with the same name. So, $name.
64
Going Further
• The Symfony Routing system;
• SymfonyCasts Routes, Controllers & Pages tutorial;
• Annotations in PHP;
• The HttpFoundation component;
• XSS (Cross-Site Scripting) security attacks;
• The Symfony Routing Cheat Sheet.
65
Step 7
Setting up a Database
docker-compose.yaml
version: '3'
services:
database:
67
image: postgres:13-alpine
environment:
POSTGRES_USER: main
POSTGRES_PASSWORD: main
POSTGRES_DB: main
ports: [5432]
The pdo_pgsql extension should have been installed when PHP was set
up in a previous step.
Wait a bit to let the database start up and check that everything is running
fine:
$ docker-compose ps
If there are no running containers or if the State column does not read Up,
check the Docker Compose logs:
$ docker-compose logs
68
7.3 Accessing the Local Database
Using the psql command-line utility might prove useful from time to
time. But you need to remember the credentials and the database name.
Less obvious, you also need to know the local port the database runs on
the host. Docker chooses a random port so that you can work on more
than one project using PostgreSQL at the same time (the local port is part
of the output of docker-compose ps).
If you run psql via the Symfony CLI, you don’t need to remember
anything.
The Symfony CLI automatically detects the Docker services running for
the project and exposes the environment variables that psql needs to
connect to the database.
Thanks to these conventions, accessing the database via symfony run is
much easier:
$ symfony run psql
If you don’t have the psql binary on your local host, you can also run
it via docker-compose:
$ docker-compose exec database psql main
69
Never call docker-compose down if you don’t want to lose data. Or
backup first.
.symfony/services.yaml
db:
type: postgresql:13
disk: 1024
size: S
.symfony.cloud.yaml
relationships:
database: "db:postgresql"
.symfony.cloud.yaml
runtime:
extensions:
- pdo_pgsql
# other extensions here
--- a/.symfony.cloud.yaml
70
+++ b/.symfony.cloud.yaml
@@ -4,6 +4,7 @@ type: php:7.4
runtime:
extensions:
+ - pdo_pgsql
- apcu
- mbstring
- sodium
@@ -21,6 +22,9 @@ build:
disk: 512
+relationships:
+ database: "db:postgresql"
+
web:
locations:
"/":
71
variables on the local machine. You must explicitly do so by using the --
expose-env-vars flag. Why? Connecting to the production database is a
dangerous operation. You can mess with real data. Requiring the flag is
how you confirm that this is what you want to do.
Now, connect to the remote PostgreSQL database via symfony run psql as
before:
$ symfony run psql
$ symfony var:export
PGHOST=127.0.0.1
PGPORT=32781
PGDATABASE=main
PGUSER=main
PGPASSWORD=main
# ...
The PG* environment variables are read by the psql utility. What about
the others?
When a tunnel is open to SymfonyCloud with the --expose-env-vars flag
set, the var:export command returns remote environment variables:
72
$ symfony tunnel:open --expose-env-vars
$ symfony var:export
$ symfony tunnel:close
Going Further
• SymfonyCloud services;
• SymfonyCloud tunnel;
• PostgreSQL documentation;
• docker-compose commands.
73
Step 8
Describing the Data Structure
To deal with the database from PHP, we are going to depend on Doctrine,
a set of libraries that help developers manage databases:
$ symfony composer req "orm:^2"
75
carefully to work for most applications.
$ symfony var:export
DATABASE_URL=postgres://main:[email protected]:32781/
main?sslmode=disable&charset=utf8
# ...
Databases are not the only service that benefit from the Symfony
conventions. The same goes for Mailer, for example (via the
MAILER_DSN environment variable).
76
8.3 Changing the Default DATABASE_URL Value in
.env
We will still change the .env file to setup the default DATABASE_URL to use
PostgreSQL:
--- a/.env
+++ b/.env
@@ -24,5 +24,5 @@ APP_SECRET=ce2ae8138936039d22afb20f4596fe97
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://db_user:[email protected]:3306/
db_name?serverVersion=5.7"
-DATABASE_URL="postgresql://db_user:[email protected]:5432/
db_name?serverVersion=13&charset=utf8"
+DATABASE_URL="postgresql://127.0.0.1:5432/db?serverVersion=13&charset=utf8"
###< doctrine/doctrine-bundle ###
The Maker bundle can help us generate a class (an Entity class) that
represents a conference:
$ symfony console make:entity Conference
77
This command is interactive: it will guide you through the process of
adding all the fields you need. Use the following answers (most of them
are the defaults, so you can hit the “Enter” key to use them):
• city, string, 255, no;
• year, string, 4, no;
• isInternational, boolean, no.
updated: src/Entity/Conference.php
Add another property? Enter the property name (or press <return> to stop
adding fields):
> year
updated: src/Entity/Conference.php
Add another property? Enter the property name (or press <return> to stop
adding fields):
78
> isInternational
updated: src/Entity/Conference.php
Add another property? Enter the property name (or press <return> to stop
adding fields):
>
Success!
The Conference class has been stored under the App\Entity\ namespace.
The command also generated a Doctrine repository class: App\Repository\
ConferenceRepository.
The generated code looks like the following (only a small portion of the
file is replicated here):
src/App/Entity/Conference.php
namespace App\Entity;
use App\Repository\ConferenceRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity(repositoryClass=ConferenceRepository::class)
*/
class Conference
{
/**
* @ORM\Id()
* @ORM\GeneratedValue()
* @ORM\Column(type="integer")
*/
private $id;
79
/**
* @ORM\Column(type="string", length=255)
*/
private $city;
// ...
return $this;
}
// ...
}
Note that the class itself is a plain PHP class with no signs of Doctrine.
Annotations are used to add metadata useful for Doctrine to map the
class to its related database table.
Doctrine added an id property to store the primary key of the row in
the database table. This key (@ORM\Id()) is automatically generated (@ORM\
GeneratedValue()) via a strategy that depends on the database engine.
Now, generate an Entity class for conference comments:
$ symfony console make:entity Comment
80
8.5 Linking Entities
The two entities, Conference and Comment, should be linked together.
A Conference can have zero or more Comments, which is called a one-to-
many relationship.
Use the make:entity command again to add this relationship to the
Conference class:
NOTE: If a Comment may *change* from one Conference to another, answer "no".
updated: src/Entity/Conference.php
updated: src/Entity/Comment.php
81
If you enter ? as an answer for the type, you will get all supported
types:
Main types
* string
* text
* boolean
* integer (or smallint, bigint)
* float
Relationships / Associations
* relation (a wizard will help you build the relation)
* ManyToOne
* OneToMany
* ManyToMany
* OneToOne
Array/Object Types
* array (or simple_array)
* json
* object
* binary
* blob
Date/Time Types
* datetime (or datetime_immutable)
* datetimetz (or datetimetz_immutable)
* date (or date_immutable)
* time (or time_immutable)
* dateinterval
Other Types
* decimal
* guid
* json_array
Have a look at the full diff for the entity classes after adding the
relationship:
--- a/src/Entity/Comment.php
+++ b/src/Entity/Comment.php
@@ -36,6 +36,12 @@ class Comment
*/
private $createdAt;
+ /**
+ * @ORM\ManyToOne(targetEntity=Conference::class, inversedBy="comments")
+ * @ORM\JoinColumn(nullable=false)
82
+ */
+ private $conference;
+
public function getId(): ?int
{
return $this->id;
@@ -88,4 +94,16 @@ class Comment
return $this;
}
+
+ public function getConference(): ?Conference
+ {
+ return $this->conference;
+ }
+
+ public function setConference(?Conference $conference): self
+ {
+ $this->conference = $conference;
+
+ return $this;
+ }
}
--- a/src/Entity/Conference.php
+++ b/src/Entity/Conference.php
@@ -2,6 +2,8 @@
namespace App\Entity;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
/**
@@ -31,6 +33,16 @@ class Conference
*/
private $isInternational;
+ /**
+ * @ORM\OneToMany(targetEntity=Comment::class, mappedBy="conference",
orphanRemoval=true)
+ */
+ private $comments;
+
+ public function __construct()
+ {
+ $this->comments = new ArrayCollection();
+ }
+
83
public function getId(): ?int
{
return $this->id;
@@ -71,4 +83,35 @@ class Conference
return $this;
}
+
+ /**
+ * @return Collection|Comment[]
+ */
+ public function getComments(): Collection
+ {
+ return $this->comments;
+ }
+
+ public function addComment(Comment $comment): self
+ {
+ if (!$this->comments->contains($comment)) {
+ $this->comments[] = $comment;
+ $comment->setConference($this);
+ }
+
+ return $this;
+ }
+
+ public function removeComment(Comment $comment): self
+ {
+ if ($this->comments->contains($comment)) {
+ $this->comments->removeElement($comment);
+ // set the owning side to null (unless already changed)
+ if ($comment->getConference() === $this) {
+ $comment->setConference(null);
+ }
+ }
+
+ return $this;
+ }
}
Everything you need to manage the relationship has been generated for
you. Once generated, the code becomes yours; feel free to customize it
the way you want.
84
8.6 Adding more Properties
I just realized that we have forgotten to add one property on the
Comment entity: attendees might want to attach a photo of the
conference to illustrate their feedback.
Run make:entity once more and add a photoFilename property/column of
type string, but allow it to be null as uploading a photo is optional:
$ symfony console make:entity Comment
Notice the generated file name in the output (a name that looks like
migrations/Version20191019083640.php):
migrations/Version20191019083640.php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
85
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SEQUENCE comment_id_seq INCREMENT BY 1 MINVALUE 1
START 1');
$this->addSql('CREATE SEQUENCE conference_id_seq INCREMENT BY 1
MINVALUE 1 START 1');
$this->addSql('CREATE TABLE comment (id INT NOT NULL, conference_id INT
NOT NULL, author VARCHAR(255) NOT NULL, text TEXT NOT NULL, email VARCHAR(255)
NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, photo_filename
VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_9474526C604B8382 ON comment
(conference_id)');
$this->addSql('CREATE TABLE conference (id INT NOT NULL, city
VARCHAR(255) NOT NULL, year VARCHAR(4) NOT NULL, is_international BOOLEAN NOT
NULL, PRIMARY KEY(id))');
$this->addSql('ALTER TABLE comment ADD CONSTRAINT FK_9474526C604B8382
FOREIGN KEY (conference_id) REFERENCES conference (id) NOT DEFERRABLE INITIALLY
IMMEDIATE');
}
The local database schema is now up-to-date, ready to store some data.
86
When deploying the project, SymfonyCloud updates the code, but also
runs the database migration if any (it detects if the
doctrine:migrations:migrate command exists).
Going Further
• Databases and Doctrine ORM in Symfony applications;
• SymfonyCasts Doctrine tutorial;
• Working with Doctrine Associations/Relations;
• DoctrineMigrationsBundle docs.
87
Step 9
Setting up an Admin Backend
89
$ mkdir src/Controller/Admin/
src/Controller/Admin/DashboardController.php
namespace App\Controller\Admin;
use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard;
use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
By convention, all admin controllers are stored under their own App\
Controller\Admin namespace.
Access the generated admin backend at /admin as configured by the
90
index() method; you can change the URL to anything you like:
••• /admin
Select 1 to create an admin interface for conferences and use the defaults
for the other questions. The following file should be generated:
src/Controller/Admin/ConferenceCrudController.php
namespace App\Controller\Admin;
91
use App\Entity\Conference;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
/*
public function configureFields(string $pageName): iterable
{
return [
IdField::new('id'),
TextField::new('title'),
TextEditorField::new('description'),
];
}
*/
}
The last step is to link the conference and comment admin CRUDs to the
dashboard:
--- a/src/Controller/Admin/DashboardController.php
+++ b/src/Controller/Admin/DashboardController.php
@@ -2,6 +2,8 @@
namespace App\Controller\Admin;
+use App\Entity\Comment;
+use App\Entity\Conference;
use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard;
use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController;
@@ -26,7 +28,8 @@ class DashboardController extends AbstractDashboardController
92
+ yield MenuItem::linktoRoute('Back to the website', 'fas fa-home',
'homepage');
+ yield MenuItem::linkToCrud('Conferences', 'fas fa-map-marker-alt',
Conference::class);
+ yield MenuItem::linkToCrud('Comments', 'fas fa-comments',
Comment::class);
}
}
--- a/src/Controller/Admin/DashboardController.php
+++ b/src/Controller/Admin/DashboardController.php
@@ -7,6 +7,7 @@ use App\Entity\Conference;
use EasyCorp\Bundle\EasyAdminBundle\Config\Dashboard;
use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractDashboardController;
+use EasyCorp\Bundle\EasyAdminBundle\Router\CrudUrlGenerator;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
93
conference. By default, it uses a convention that uses the entity name
and the primary key (like Conference #1) if the entity does not define
the “magic” __toString() method. To make the display more meaningful,
add such a method on the Conference class:
--- a/src/Entity/Conference.php
+++ b/src/Entity/Conference.php
@@ -44,6 +44,11 @@ class Conference
$this->comments = new ArrayCollection();
}
--- a/src/Entity/Comment.php
+++ b/src/Entity/Comment.php
@@ -48,6 +48,11 @@ class Comment
*/
private $photoFilename;
94
••• /admin
Add some comments without photos. Set the date manually for now; we
will fill-in the createdAt column automatically in a later step.
••• /admin?crudAction=index&crudId=2bfa220&menuIndex=2&submenuIn
dex=-1
95
Comment entity to demonstrate some possibilities:
--- a/src/Controller/Admin/CommentCrudController.php
+++ b/src/Controller/Admin/CommentCrudController.php
@@ -3,7 +3,15 @@
namespace App\Controller\Admin;
use App\Entity\Comment;
+use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
+use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
+use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
+use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
+use EasyCorp\Bundle\EasyAdminBundle\Field\EmailField;
+use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
+use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
+use EasyCorp\Bundle\EasyAdminBundle\Filter\EntityFilter;
- /*
+ public function configureCrud(Crud $crud): Crud
+ {
+ return $crud
+ ->setEntityLabelInSingular('Conference Comment')
+ ->setEntityLabelInPlural('Conference Comments')
+ ->setSearchFields(['author', 'text', 'email'])
+ ->setDefaultSort(['createdAt' => 'DESC']);
+ ;
+ }
+
+ public function configureFilters(Filters $filters): Filters
+ {
+ return $filters
+ ->add(EntityFilter::new('conference'))
+ ;
+ }
+
public function configureFields(string $pageName): iterable
{
- return [
- IdField::new('id'),
- TextField::new('title'),
- TextEditorField::new('description'),
- ];
+ yield AssociationField::new('conference');
96
+ yield TextField::new('author');
+ yield EmailField::new('email');
+ yield TextareaField::new('text')
+ ->hideOnIndex()
+ ;
+ yield TextField::new('photoFilename')
+ ->onlyOnIndex()
+ ;
+
+ $createdAt = DateTimeField::new('createdAt')->setFormTypeOptions([
+ 'html5' => true,
+ 'years' => range(date('Y'), date('Y') + 5),
+ 'widget' => 'single_text',
+ ]);
+ if (Crud::PAGE_EDIT === $pageName) {
+ yield $createdAt->setFormTypeOption('disabled', true);
+ } else {
+ yield $createdAt;
+ }
}
- */
}
••• /admin?crudAction=index&crudId=2bfa220&menuIndex=2&submenuIn
dex=-1
97
These customizations are just a small introduction of the possibilities
given by EasyAdmin.
Play with the admin, filter the comments by conference, or search
comments by email for instance. The only issue is that anybody can
access the backend. Don’t worry, we will secure it in a future step.
Going Further
• EasyAdmin docs;
• Symfony framework configuration reference;
• PHP magic methods.
98
Step 10
Building the User Interface
Everything is now in place to create the first version of the website user
interface. We won’t make it pretty. Just functional for now.
Remember the escaping we had to do in the controller for the easter
egg to avoid security issues? We won’t use PHP for our templates for
that reason. Instead, we will use Twig. Besides handling output escaping
for us, Twig brings a lot of nice features we will leverage, like template
inheritance.
99
Twig, independently of EasyAdmin. Adding it like any other dependency
is enough:
$ symfony composer req twig
templates/base.html.twig
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title>
{% block stylesheets %}{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
{% block javascripts %}{% endblock %}
</body>
</html>
A layout can define block elements, which are the places where child
templates that extend the layout add their contents.
Let’s create a template for the project’s homepage in templates/
100
conference/index.html.twig:
templates/conference/index.html.twig
{% extends 'base.html.twig' %}
{% block body %}
<h2>Give your feedback!</h2>
The template extends base.html.twig and redefines the title and body
blocks.
The {% %} notation in a template indicates actions and structure.
The {{ }} notation is used to display something. {{ conference }} displays
the conference representation (the result of calling __toString on the
Conference object).
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -2,22 +2,19 @@
namespace App\Controller;
+use App\Repository\ConferenceRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
+use Twig\Environment;
101
+ public function index(Environment $twig, ConferenceRepository
$conferenceRepository): Response
{
- return new Response(<<<EOF
-<html>
- <body>
- <img src="/images/under-construction.gif" />
- </body>
-</html>
-EOF
- );
+ return new Response($twig->render('conference/index.html.twig', [
+ 'conferences' => $conferenceRepository->findAll(),
+ ]));
}
}
102
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -2,6 +2,8 @@
namespace App\Controller;
+use App\Entity\Conference;
+use App\Repository\CommentRepository;
use App\Repository\ConferenceRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
@@ -17,4 +19,13 @@ class ConferenceController extends AbstractController
'conferences' => $conferenceRepository->findAll(),
]));
}
+
+ #[Route('/conference/{id}', name: 'conference')]
+ public function show(Environment $twig, Conference $conference,
CommentRepository $commentRepository): Response
+ {
+ return new Response($twig->render('conference/show.html.twig', [
+ 'conference' => $conference,
+ 'comments' => $commentRepository->findBy(['conference' =>
$conference], ['createdAt' => 'DESC']),
+ ]));
+ }
}
This method has a special behavior we have not seen yet. We ask for a
Conference instance to be injected in the method. But there may be many
of these in the database. Symfony is able to determine which one you
want based on the {id} passed in the request path (id being the primary
key of the conference table in the database).
Retrieving the comments related to the conference can be done via the
findBy() method which takes a criteria as a first argument.
The last step is to create the templates/conference/show.html.twig file:
templates/conference/show.html.twig
{% extends 'base.html.twig' %}
{% block body %}
<h2>{{ conference }} Conference</h2>
103
{% if comments|length > 0 %}
{% for comment in comments %}
{% if comment.photofilename %}
<img src="{{ asset('uploads/photos/' ~ comment.photofilename)
}}" />
{% endif %}
In this template, we are using the | notation to call Twig filters. A filter
transforms a value. comments|length returns the number of comments and
comment.createdAt|format_datetime('medium', 'short') formats the date
in a human readable representation.
Try to reach the “first” conference via /conference/1, and notice the
following error:
••• /conference/1
104
The error comes from the format_datetime filter as it is not part of Twig
core. The error message gives you a hint about which package should be
installed to fix the problem:
$ symfony composer req "twig/intl-extra:^3"
--- a/templates/conference/index.html.twig
+++ b/templates/conference/index.html.twig
@@ -7,5 +7,8 @@
But hard-coding a path is a bad idea for several reasons. The most
important reason is if you change the path (from /conference/{id} to
/conferences/{id} for instance), all links must be updated manually.
Instead, use the path() Twig function and use the route name:
--- a/templates/conference/index.html.twig
+++ b/templates/conference/index.html.twig
@@ -8,7 +8,7 @@
{% for conference in conferences %}
<h4>{{ conference }}</h4>
<p>
- <a href="/conference/{{ conference.id }}">View</a>
+ <a href="{{ path('conference', { id: conference.id }) }}">View</a>
</p>
{% endfor %}
{% endblock %}
105
The path() function generates the path to a page using its route name.
The values of the route parameters are passed as a Twig map.
--- a/src/Repository/CommentRepository.php
+++ b/src/Repository/CommentRepository.php
@@ -3,8 +3,10 @@
namespace App\Repository;
use App\Entity\Comment;
+use App\Entity\Conference;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
+use Doctrine\ORM\Tools\Pagination\Paginator;
/**
* @method Comment|null find($id, $lockMode = null, $lockVersion = null)
@@ -14,11 +16,27 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class CommentRepository extends ServiceEntityRepository
{
+ public const PAGINATOR_PER_PAGE = 2;
+
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Comment::class);
}
106
+ ->getQuery()
+ ;
+
+ return new Paginator($query);
+ }
+
// /**
// * @return Comment[] Returns an array of Comment objects
// */
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -6,6 +6,7 @@ use App\Entity\Conference;
use App\Repository\CommentRepository;
use App\Repository\ConferenceRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;
@@ -21,11 +22,16 @@ class ConferenceController extends AbstractController
}
107
]));
}
}
The controller gets the offset from the Request query string ($request-
>query) as an integer (getInt()), defaulting to 0 if not available.
The previous and next offsets are computed based on all the information
we have from the paginator.
Finally, update the template to add links to the next and previous pages:
--- a/templates/conference/show.html.twig
+++ b/templates/conference/show.html.twig
@@ -6,6 +6,8 @@
<h2>{{ conference }} Conference</h2>
{% if comments|length > 0 %}
+ <div>There are {{ comments|length }} comments.</div>
+
{% for comment in comments %}
{% if comment.photofilename %}
<img src="{{ asset('uploads/photos/' ~ comment.photofilename)
}}" />
@@ -18,6 +20,13 @@
You should now be able to navigate the comments via the “Previous” and
“Next” links:
108
••• /conference/1
••• /conference/1?offset=2
109
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -13,21 +13,28 @@ use Twig\Environment;
110
Going Further
• Twig docs;
• Creating and Using Templates in Symfony applications;
• SymfonyCasts Twig tutorial;
• Twig functions and filters only available in Symfony;
• The AbstractController base controller.
111
Step 11
Branching the Code
113
11.2 Creating Branches
The workflow starts with the creation of a Git branch:
$ git checkout -b sessions-in-db
114
--- a/config/packages/framework.yaml
+++ b/config/packages/framework.yaml
@@ -7,7 +7,7 @@ framework:
# Enables session support. Note that the session will ONLY be started if
you read or write from it.
# Remove or comment this section to explicitly disable session support.
session:
- handler_id: null
+ handler_id: '%env(DATABASE_URL)%'
cookie_secure: auto
cookie_samesite: lax
Edit the file to add the table creation in the up() method:
--- a/migrations/Version00000000000000.php
+++ b/migrations/Version00000000000000.php
@@ -21,6 +21,14 @@ final class Version00000000000000 extends AbstractMigration
{
// this up() migration is auto-generated, please modify it to your
needs
+ $this->addSql('
+ CREATE TABLE sessions (
+ sess_id VARCHAR(128) NOT NULL PRIMARY KEY,
+ sess_data BYTEA NOT NULL,
+ sess_lifetime INTEGER NOT NULL,
+ sess_time INTEGER NOT NULL
+ )
+ ');
}
Test locally by browsing the website. As there are no visual changes and
because we are not using sessions yet, everything should still work as
before.
115
We don’t need steps 3 to 5 here as we are re-using the database as
the session storage, but the chapter about using Redis shows how
straightforward it is to add, test, and deploy a new service in both
Docker and SymfonyCloud.
--- a/config/packages/doctrine.yaml
+++ b/config/packages/doctrine.yaml
@@ -5,6 +5,8 @@ doctrine:
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '13'
+
+ schema_filter: ~^(?!session)~
orm:
auto_generate_proxy_classes: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
116
• The data come from the master (aka production) environment by
taking a consistent snapshot of all service data, including files (user
uploaded files for instance) and databases;
• A new dedicated cluster is created to deploy the code, the data, and
the infrastructure.
Note that all SymfonyCloud commands work on the current Git branch.
This command opens the deployed URL for the sessions-in-db branch;
the URL will look like https://sessions-in-db-xxx.eu.s5y.io/.
Test the website on this new environment, you should see all the data that
you created in the master environment.
If you add more conferences on the master environment, they won’t show
up in the sessions-in-db environment and vice-versa. The environments
are independent and isolated.
If the code evolves on master, you can always rebase the Git branch and
deploy the updated version, resolving the conflicts for both the code and
the infrastructure.
You can even synchronize the data from master back to the sessions-in-
db environment:
$ symfony env:sync
117
11.5 Debugging Production Deployments before
Deploying
By default, all SymfonyCloud environments use the same settings as
the master/prod environment (aka the Symfony prod environment). This
allows you to test the application in real-life conditions. It gives you
the feeling of developing and testing directly on production servers, but
without the risks associated with it. This reminds me of the good old days
when we were deploying via FTP.
In case of a problem, you might want to switch to the dev Symfony
environment:
$ symfony env:debug
Never enable the dev environment and never enable the Symfony
Profiler on the master branch; it would make your application really
slow and open a lot of serious security vulnerabilities.
118
11.7 Merging to Production
When you are satisfied with the branch changes, merge the code and the
infrastructure back to the Git master branch:
$ git checkout master
$ git merge sessions-in-db
And deploy:
$ symfony deploy
When deploying, only the code and infrastructure changes are pushed to
SymfonyCloud; the data are not affected in any way.
11.8 Cleaning up
Finally, clean up by removing the Git branch and the SymfonyCloud
environment:
$ git branch -d sessions-in-db
$ symfony env:delete --env=sessions-in-db --no-interaction
Going Further
• Git branching;
119
Step 12
Listening to Events
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -8,6 +8,15 @@
{% block javascripts %}{% endblock %}
</head>
<body>
+ <header>
+ <h1><a href="{{ path('homepage') }}">Guestbook</a></h1>
+ <ul>
+ {% for conference in conferences %}
+ <li><a href="{{ path('conference', { id: conference.id })
}}">{{ conference }}</a></li>
+ {% endfor %}
121
+ </ul>
+ <hr />
+ </header>
{% block body %}{% endblock %}
</body>
</html>
Adding this code to the layout means that all templates extending it must
define a conferences variable, which must be created and passed from
their controllers.
As we only have two controllers, you might do the following (do not apply
the change to your code as we will learn a better way very soon):
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -29,12 +29,13 @@ class ConferenceController extends AbstractController
}
122
12.2 Discovering Symfony Events
Symfony comes built-in with an Event Dispatcher Component. A
dispatcher dispatches certain events at specific times that listeners can
listen to. Listeners are hooks into the framework internals.
For instance, some events allow you to interact with the lifecycle of HTTP
requests. During the handling of a request, the dispatcher dispatches
events when a request has been created, when a controller is about to be
executed, when a response is ready to be sent, or when an exception has
been thrown. A listener can listen to one or more events and execute some
logic based on the event context.
Events are well-defined extension points that make the framework more
generic and extensible. Many Symfony Components like Security,
Messenger, Workflow, or Mailer use them extensively.
Another built-in example of events and listeners in action is the lifecycle
of a command: you can create a listener to execute code before any
command is run.
Any package or bundle can also dispatch their own events to make their
code extensible.
To avoid having a configuration file that describes which events a listener
wants to listen to, create a subscriber. A subscriber is a listener with a
static getSubscribedEvents() method that returns its configuration. This
allows subscribers to be registered in the Symfony dispatcher
automatically.
The command asks you about which event you want to listen to. Choose
the Symfony\Component\HttpKernel\Event\ControllerEvent event, which is
123
dispatched just before the controller is called. It is the best time to inject
the conferences global variable so that Twig will have access to it when
the controller will render the template. Update your subscriber as follows:
--- a/src/EventSubscriber/TwigEventSubscriber.php
+++ b/src/EventSubscriber/TwigEventSubscriber.php
@@ -2,14 +2,25 @@
namespace App\EventSubscriber;
+use App\Repository\ConferenceRepository;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
+use Twig\Environment;
Now, you can add as many controllers as you want: the conferences
variable will always be available in Twig.
124
12.4 Sorting Conferences by Year and City
Ordering the conference list by year may facilitate browsing. We could
create a custom method to retrieve and sort all conferences, but instead,
we are going to override the default implementation of the findAll()
method to be sure that sorting applies everywhere:
--- a/src/Repository/ConferenceRepository.php
+++ b/src/Repository/ConferenceRepository.php
@@ -19,6 +19,11 @@ class ConferenceRepository extends ServiceEntityRepository
parent::__construct($registry, Conference::class);
}
At the end of this step, the website should look like the following:
••• /
125
Going Further
• The Request-Response Flow in Symfony applications;
• The built-in Symfony HTTP events;
• The built-in Symfony Console events.
126
Step 13
Managing the Lifecycle of
Doctrine Objects
--- a/src/Entity/Comment.php
+++ b/src/Entity/Comment.php
@@ -7,6 +7,7 @@ use Doctrine\ORM\Mapping as ORM;
127
/**
* @ORM\Entity(repositoryClass=CommentRepository::class)
+ * @ORM\HasLifecycleCallbacks()
*/
class Comment
{
@@ -106,6 +107,14 @@ class Comment
return $this;
}
+ /**
+ * @ORM\PrePersist
+ */
+ public function setCreatedAtValue()
+ {
+ $this->createdAt = new \DateTime();
+ }
+
public function getConference(): ?Conference
{
return $this->conference;
128
$ symfony console make:migration
Got an error? This is expected. Why? Because we asked for the slug to
be not null but existing entries in the conference database will get a null
value when the migration is ran. Let’s fix that by tweaking the migration:
--- a/migrations/Version00000000000000.php
+++ b/migrations/Version00000000000000.php
@@ -20,7 +20,9 @@ final class Version20200714152808 extends AbstractMigration
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your
needs
- $this->addSql('ALTER TABLE conference ADD slug VARCHAR(255) NOT NULL');
+ $this->addSql('ALTER TABLE conference ADD slug VARCHAR(255)');
+ $this->addSql("UPDATE conference SET slug=CONCAT(LOWER(city), '-',
year)");
+ $this->addSql('ALTER TABLE conference ALTER COLUMN slug SET NOT NULL');
}
The trick here is to add the column and allow it to be null, then set the
slug to a not null value, and finally, change the slug column to not allow
null.
Because the application will soon use slugs to find each conference, let’s
tweak the Conference entity to ensure that slug values are unique in the
database:
129
--- a/src/Entity/Conference.php
+++ b/src/Entity/Conference.php
@@ -6,9 +6,11 @@ use App\Repository\ConferenceRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
/**
* @ORM\Entity(repositoryClass=ConferenceRepository::class)
+ * @UniqueEntity("slug")
*/
class Conference
{
@@ -40,7 +42,7 @@ class Conference
private $comments;
/**
- * @ORM\Column(type="string", length=255)
+ * @ORM\Column(type="string", length=255, unique=true)
*/
private $slug;
130
component, which eases the manipulation of strings and provides a
slugger:
$ symfony composer req string
--- a/src/Entity/Conference.php
+++ b/src/Entity/Conference.php
@@ -7,6 +7,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\String\Slugger\SluggerInterface;
/**
* @ORM\Entity(repositoryClass=ConferenceRepository::class)
@@ -61,6 +62,13 @@ class Conference
return $this->id;
}
The computeSlug() method only computes a slug when the current slug is
empty or set to the special - value. Why do we need the - special value?
Because when adding a conference in the backend, the slug is required.
So, we need a non-empty value that tells the application that we want the
slug to be automatically generated.
131
But as this method depends on a SluggerInterface implementation, we
cannot add a prePersist event as before (we don’t have a way to inject the
slugger).
Instead, create a Doctrine entity listener:
src/EntityListener/ConferenceEntityListener.php
namespace App\EntityListener;
use App\Entity\Conference;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Symfony\Component\String\Slugger\SluggerInterface;
class ConferenceEntityListener
{
private $slugger;
132
a slugger, etc.) unlike data objects (e.g. Doctrine entity instances).
You rarely interact with the container directly as it automatically injects
service objects whenever you need them: the container injects the
controller argument objects when you type-hint them for instance.
If you wondered how the event listener was registered in the previous
step, you now have the answer: the container. When a class implements
some specific interfaces, the container knows that the class needs to be
registered in a certain way.
Unfortunately, automation is not provided for everything, especially for
third-party packages. The entity listener that we just wrote is one such
example; it cannot be managed by the Symfony service container
automatically as it does not implement any interface and it does not
extend a “well-know class”.
We need to partially declare the listener in the container. The dependency
wiring can be omitted as it can still be guessed by the container, but we
need to manually add some tags to register the listener with the Doctrine
event dispatcher:
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -29,3 +29,7 @@ services:
Don’t confuse Doctrine event listeners and Symfony ones. Even if they
look very similar, they are not using the same infrastructure under the
hood.
133
13.6 Using Slugs in the Application
Try adding more conferences in the backend and change the city or the
year of an existing one; the slug won’t be updated except if you use the
special - value.
The last change is to update the controllers and the templates to use the
conference slug instead of the conference id for routes:
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -28,7 +28,7 @@ class ConferenceController extends AbstractController
]));
}
134
+++ b/templates/conference/show.html.twig
@@ -22,10 +22,10 @@
{% endfor %}
{% if previous >= 0 %}
- <a href="{{ path('conference', { id: conference.id, offset:
previous }) }}">Previous</a>
+ <a href="{{ path('conference', { slug: conference.slug, offset:
previous }) }}">Previous</a>
{% endif %}
{% if next < comments|length %}
- <a href="{{ path('conference', { id: conference.id, offset: next
}) }}">Next</a>
+ <a href="{{ path('conference', { slug: conference.slug, offset:
next }) }}">Next</a>
{% endif %}
{% else %}
<div>No comments have been posted yet for this conference.</div>
••• /conference/amsterdam-2019
135
Going Further
• The Doctrine event system (lifecycle callbacks and listeners, entity
listeners and lifecycle subscribers);
• The String component docs;
• The Service container;
• The Symfony Services Cheat Sheet.
136
Step 14
Accepting Feedback with Forms
created: src/Form/CommentFormType.php
Success!
137
src/App/Form/CommentFormType.php
namespace App\Form;
use App\Entity\Comment;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
A form type describes the form fields bound to a model. It does the data
conversion between submitted data and the model class properties. By
default, Symfony uses metadata from the Comment entity - such as the
Doctrine metadata - to guess configuration about each field. For example,
the text field renders as a textarea because it uses a larger column in the
database.
--- a/src/Controller/ConferenceController.php
138
+++ b/src/Controller/ConferenceController.php
@@ -2,7 +2,9 @@
namespace App\Controller;
+use App\Entity\Comment;
use App\Entity\Conference;
+use App\Form\CommentFormType;
use App\Repository\CommentRepository;
use App\Repository\ConferenceRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -31,6 +33,9 @@ class ConferenceController extends AbstractController
#[Route('/conference/{slug}', name: 'conference')]
public function show(Request $request, Conference $conference,
CommentRepository $commentRepository): Response
{
+ $comment = new Comment();
+ $form = $this->createForm(CommentFormType::class, $comment);
+
$offset = max(0, $request->query->getInt('offset', 0));
$paginator = $commentRepository->getCommentPaginator($conference,
$offset);
You should never instantiate the form type directly. Instead, use the
createForm() method. This method is part of AbstractController and
eases the creation of forms.
When passing a form to a template, use createView() to convert the data
to a format suitable for templates.
Displaying the form in the template can be done via the form Twig
function:
--- a/templates/conference/show.html.twig
+++ b/templates/conference/show.html.twig
@@ -30,4 +30,8 @@
{% else %}
139
<div>No comments have been posted yet for this conference.</div>
{% endif %}
+
+ <h2>Add your own feedback</h2>
+
+ {{ form(comment_form) }}
{% endblock %}
When refreshing a conference page in the browser, note that each form
field shows the right HTML widget (the data type is derived from the
model):
••• /conference/amsterdam-2019
The form() function generates the HTML form based on all the
information defined in the Form type. It also adds enctype=multipart/
form-data on the <form> tag as required by the file upload input field.
Moreover, it takes care of displaying error messages when the submission
has some errors. Everything can be customized by overriding the default
templates, but we won’t need it for this project.
140
--- a/src/Form/CommentFormType.php
+++ b/src/Form/CommentFormType.php
@@ -4,20 +4,31 @@ namespace App\Form;
use App\Entity\Comment;
use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\EmailType;
+use Symfony\Component\Form\Extension\Core\Type\FileType;
+use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
+use Symfony\Component\Validator\Constraints\Image;
Note that we have added a submit button (that allows us to keep using
the simple {{ form(comment_form) }} expression in the template).
Some fields cannot be auto-configured, like the photoFilename one. The
Comment entity only needs to save the photo filename, but the form has
to deal with the file upload itself. To handle this case, we have added a
field called photo as un-mapped field: it won’t be mapped to any property
on Comment. We will manage it manually to implement some specific logic
141
(like storing the uploaded photo on the disk).
As an example of customization, we have also modified the default label
for some fields.
The image constraint works by checking the mime type; require the Mime
component to make it work:
$ symfony composer req mime
••• /conference/amsterdam-2019
142
required="required"></textarea>
</div>
<div >
<label for="comment_form_email" class="required">Email</label>
<input type="email" id="comment_form_email"
name="comment_form[email]" required="required" />
</div>
<div >
<label for="comment_form_photo">Photo</label>
<input type="file" id="comment_form_photo"
name="comment_form[photo]" />
</div>
<div >
<button type="submit" id="comment_form_submit"
name="comment_form[submit]">Submit</button>
</div>
<input type="hidden" id="comment_form__token"
name="comment_form[_token]" value="DwqsEanxc48jofxsqbGBVLQBqlVJ_Tg4u9-BL1Hjgac"
/>
</div>
</form>
The form uses the email input for the comment email and makes most of
the fields required. Note that the form also contains a _token hidden field
to protect the form from CSRF attacks.
But if the form submission bypasses the HTML validation (by using an
HTTP client that does not enforce these validation rules like cURL),
invalid data can hit the server.
We also need to add some validation constraints on the Comment data
model:
--- a/src/Entity/Comment.php
+++ b/src/Entity/Comment.php
@@ -4,6 +4,7 @@ namespace App\Entity;
use App\Repository\CommentRepository;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity(repositoryClass=CommentRepository::class)
@@ -21,16 +22,20 @@ class Comment
/**
* @ORM\Column(type="string", length=255)
143
*/
+ #[Assert\NotBlank]
private $author;
/**
* @ORM\Column(type="text")
*/
+ #[Assert\NotBlank]
private $text;
/**
* @ORM\Column(type="string", length=255)
*/
+ #[Assert\NotBlank]
+ #[Assert\Email]
private $email;
/**
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -7,6 +7,7 @@ use App\Entity\Conference;
use App\Form\CommentFormType;
use App\Repository\CommentRepository;
use App\Repository\ConferenceRepository;
+use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -16,10 +17,12 @@ use Twig\Environment;
class ConferenceController extends AbstractController
{
private $twig;
+ private $entityManager;
144
$entityManager)
{
$this->twig = $twig;
+ $this->entityManager = $entityManager;
}
145
accessible by the frontend so that we can display them on the conference
page. We will store them under the public/uploads/photos directory:
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -9,6 +9,7 @@ use App\Repository\CommentRepository;
use App\Repository\ConferenceRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
@@ -34,13 +35,22 @@ class ConferenceController extends AbstractController
}
$this->entityManager->persist($comment);
$this->entityManager->flush();
To manage photo uploads, we create a random name for the file. Then,
we move the uploaded file to its final location (the photo directory).
Finally, we store the filename in the Comment object.
Notice the new argument on the show() method? $photoDir is a string and
not a service. How can Symfony know what to inject here? The Symfony
Container is able to store parameters in addition to services. Parameters
146
are scalars that help configure services. These parameters can be injected
into services explicitly, or they can be bound by name:
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -10,6 +10,8 @@ services:
_defaults:
autowire: true # Automatically injects dependencies in your
services.
autoconfigure: true # Automatically registers your services as
commands, event subscribers, etc.
+ bind:
+ $photoDir: "%kernel.project_dir%/public/uploads/photos"
The bind setting allows Symfony to inject the value whenever a service has
a $photoDir argument.
Try to upload a PDF file instead of a photo. You should see the error
messages in action. The design is quite ugly at the moment, but don’t
worry, everything will turn beautiful in a few steps when we will work
on the design of the website. For the forms, we will change one line of
configuration to style all form elements.
But how can you access the profiler for a successful submit request?
147
Because the page is immediately redirected, we never see the web debug
toolbar for the POST request. No problem: on the redirected page, hover
over the left “200” green part. You should see the “302” redirection with
a link to the profile (in parenthesis).
••• /conference/amsterdam-2019
148
••• /_profiler/450aa5
--- a/src/Controller/Admin/CommentCrudController.php
+++ b/src/Controller/Admin/CommentCrudController.php
@@ -9,6 +9,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Controller\
AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\EmailField;
+use EasyCorp\Bundle\EasyAdminBundle\Field\ImageField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
149
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use EasyCorp\Bundle\EasyAdminBundle\Filter\EntityFilter;
@@ -45,7 +46,9 @@ class CommentCrudController extends AbstractCrudController
yield TextareaField::new('text')
->hideOnIndex()
;
- yield TextField::new('photoFilename')
+ yield ImageField::new('photoFilename')
+ ->setBasePath('/uploads/photos')
+ ->setLabel('Photo')
->onlyOnIndex()
;
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+/public/uploads
150
Have a look at .symfony.cloud.yaml, there is already a writeable mount for
the var/ directory. The var/ directory is the only directory where Symfony
writes (caches, logs, …).
Let’s create a new mount for uploaded photos:
--- a/.symfony.cloud.yaml
+++ b/.symfony.cloud.yaml
@@ -36,6 +36,7 @@ web:
mounts:
"/var": { source: local, source_path: var }
+ "/public/uploads": { source: local, source_path: uploads }
hooks:
build: |
You can now deploy the code and photos will be stored in the public/
uploads/ directory like our local version.
Going Further
• SymfonyCasts Forms tutorial;
• How to customize Symfony Form rendering in HTML;
• Validating Symfony Forms;
• The Symfony Form Types reference;
• The FlysystemBundle docs, which provides integration with
multiple cloud storage providers, such as AWS S3, Azure and
Google Cloud Storage;
• The Symfony Configuration Parameters.
• The Symfony Validation Constraints;
• The Symfony Form Cheat Sheet.
151
Step 15
Securing the Admin Backend
153
needs a password property.
Use the dedicated make:user command to create the Admin entity instead
of the traditional make:entity one:
$ symfony console make:user Admin
In addition to generating the Admin entity, the command also updated the
security configuration to wire the entity with the authentication system:
--- a/config/packages/security.yaml
+++ b/config/packages/security.yaml
@@ -1,7 +1,15 @@
security:
+ encoders:
+ App\Entity\Admin:
+ algorithm: auto
+
# https://symfony.com/doc/current/security.html#where-do-users-come-from-
154
user-providers
providers:
- in_memory: { memory: null }
+ # used to reload user from session & other features (e.g. switch_user)
+ app_user_provider:
+ entity:
+ class: App\Entity\Admin
+ property: username
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
We let Symfony select the best possible algorithm for encoding passwords
(which will evolve over time).
Time to generate a migration and migrate the database:
$ symfony console make:migration
$ symfony console doctrine:migrations:migrate -n
------------------
---------------------------------------------------------------------------------------------
Key Value
------------------
155
---------------------------------------------------------------------------------------------
Encoder used Symfony\Component\Security\Core\Encoder\MigratingPasswordEncoder
Encoded password
$argon2id$v=19$m=65536,t=4,p=1$BQG+jovPcunctc30xG5PxQ$TiGbx451NKdo+g9vLtfkMy4KjASKSOcnNxjij4g
------------------
---------------------------------------------------------------------------------------------
! [NOTE] Self-salting encoder used: the encoder generated its own built-in salt.
'\$argon2id\$v=19\$m=65536,t=4,p=1\$BQG+jovPcunctc30xG5PxQ\$TiGbx451NKdo+g9vLtfkMy4KjASKSOcnN
Note the escaping of the $ sign in the password column value; escape
them all!
156
The command updated the security configuration to wire the generated
classes:
--- a/config/packages/security.yaml
+++ b/config/packages/security.yaml
@@ -16,6 +16,13 @@ security:
security: false
main:
anonymous: lazy
+ guard:
+ authenticators:
+ - App\Security\AppAuthenticator
+ logout:
+ path: app_logout
+ # where to redirect after logout
+ # target: app_any_route
157
How do I remember that the EasyAdmin route is admin (as configured
in App\Controller\Admin\DashboardController)? I don’t. You can have
a look at the file, but you can also ran the following command that
shows the association between route names and paths:
$ symfony console debug:router
--- a/config/packages/security.yaml
+++ b/config/packages/security.yaml
@@ -35,5 +35,5 @@ security:
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- # - { path: ^/admin, roles: ROLE_ADMIN }
+ - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
158
••• /login/
Log in using admin and whatever plain-text password you encoded earlier.
If you copied my SQL command exactly, the password is admin.
Note that EasyAdmin automatically recognizes the Symfony
authentication system:
••• /admin/
Try to click on the “Sign out” link. You have it! A fully-secured backend
admin.
159
If you want to create a fully-featured form authentication system, have
a look at the make:registration-form command.
Going Further
• The Symfony Security docs;
• SymfonyCasts Security tutorial;
• How to Build a Login Form in Symfony applications;
• The Symfony Security Cheat Sheet.
160
Step 16
Preventing Spam with an API
161
To make API calls, use the Symfony HttpClient Component:
$ symfony composer req http-client
src/SpamChecker.php
namespace App;
use App\Entity\Comment;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class SpamChecker
{
private $client;
private $endpoint;
/**
* @return int Spam score: 0: not spam, 1: maybe spam, 2: blatant spam
*
* @throws \RuntimeException if the call did not work
*/
public function getSpamScore(Comment $comment, array $context): int
{
$response = $this->client->request('POST', $this->endpoint, [
'body' => array_merge($context, [
'blog' => 'https://guestbook.example.com',
'comment_type' => 'comment',
'comment_author' => $comment->getAuthor(),
'comment_author_email' => $comment->getEmail(),
'comment_content' => $comment->getText(),
'comment_date_gmt' => $comment->getCreatedAt()->format('c'),
'blog_lang' => 'en',
'blog_charset' => 'UTF-8',
'is_test' => true,
162
]),
]);
$headers = $response->getHeaders();
if ('discard' === ($headers['x-akismet-pro-tip'][0] ?? '')) {
return 2;
}
$content = $response->getContent();
if (isset($headers['x-akismet-debug-help'][0])) {
throw new \RuntimeException(sprintf('Unable to check for spam: %s
(%s).', $content, $headers['x-akismet-debug-help'][0]));
}
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -12,6 +12,7 @@ services:
autoconfigure: true # Automatically registers your services as
163
commands, event subscribers, etc.
bind:
$photoDir: "%kernel.project_dir%/public/uploads/photos"
+ $akismetKey: "%env(AKISMET_KEY)%"
We certainly don’t want to hard-code the value of the Akismet key in the
services.yaml configuration file, so we are using an environment variable
instead (AKISMET_KEY).
It is then up to each developer to set a “real” environment variable or to
store the value in a .env.local file:
.env.local
AKISMET_KEY=abcdef
164
[OK] Secret "AKISMET_KEY" encrypted in "config/secrets/dev/"; you can commit
it.
Because this is the first time we have run this command, it generated two
keys into the config/secret/dev/ directory. It then stored the AKISMET_KEY
secret in that same directory.
For development secrets, you can decide to commit the vault and the keys
that have been generated in the config/secret/dev/ directory.
Secrets can also be overridden by setting an environment variable of the
same name.
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -7,6 +7,7 @@ use App\Entity\Conference;
use App\Form\CommentFormType;
use App\Repository\CommentRepository;
use App\Repository\ConferenceRepository;
+use App\SpamChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
@@ -35,7 +36,7 @@ class ConferenceController extends AbstractController
}
165
$this->entityManager->persist($comment);
+
+ $context = [
+ 'user_ip' => $request->getClientIp(),
+ 'user_agent' => $request->headers->get('user-agent'),
+ 'referrer' => $request->headers->get('referer'),
+ 'permalink' => $request->getUri(),
+ ];
+ if (2 === $spamChecker->getSpamScore($comment, $context)) {
+ throw new \RuntimeException('Blatant spam, go away!');
+ }
+
$this->entityManager->flush();
Re-add the Akismet secret in the production vault but with its production
value:
166
$ APP_ENV=prod symfony console secrets:set AKISMET_KEY
You can add and commit all files; the decryption key has been added in
.gitignore automatically, so it will never be committed. For more safety,
you can remove it from your local machine as it has been deployed now:
$ rm -f config/secrets/prod/prod.decrypt.private.php
Going Further
• The HttpClient component docs;
• The Environment Variable Processors;
• The Symfony HttpClient Cheat Sheet.
167
Step 17
Testing
169
--- a/tests/SpamCheckerTest.php
+++ b/tests/SpamCheckerTest.php
@@ -2,12 +2,26 @@
namespace App\Tests;
+use App\Entity\Comment;
+use App\SpamChecker;
use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpClient\MockHttpClient;
+use Symfony\Component\HttpClient\Response\MockResponse;
+use Symfony\Contracts\HttpClient\ResponseInterface;
170
--- a/tests/SpamCheckerTest.php
+++ b/tests/SpamCheckerTest.php
@@ -24,4 +24,32 @@ class SpamCheckerTest extends TestCase
$this->expectExceptionMessage('Unable to check for spam: invalid
(Invalid key).');
$checker->getSpamScore($comment, $context);
}
+
+ /**
+ * @dataProvider getComments
+ */
+ public function testSpamScore(int $expectedScore, ResponseInterface
$response, Comment $comment, array $context)
+ {
+ $client = new MockHttpClient([$response]);
+ $checker = new SpamChecker($client, 'abcde');
+
+ $score = $checker->getSpamScore($comment, $context);
+ $this->assertSame($expectedScore, $score);
+ }
+
+ public function getComments(): iterable
+ {
+ $comment = new Comment();
+ $comment->setCreatedAtValue();
+ $context = [];
+
+ $response = new MockResponse('', ['response_headers' => ['x-akismet-
pro-tip: discard']]);
+ yield 'blatant_spam' => [2, $response, $comment, $context];
+
+ $response = new MockResponse('true');
+ yield 'spam' => [1, $response, $comment, $context];
+
+ $response = new MockResponse('false');
+ yield 'ham' => [0, $response, $comment, $context];
+ }
}
PHPUnit data providers allow us to reuse the same test logic for several
test cases.
171
Create a functional test for the Conference controller:
tests/Controller/ConferenceControllerTest.php
namespace App\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h2', 'Give your feedback');
}
}
We have used / for the URL instead of generating it via the router.
This is done on purpose as testing end-user URLs is part of what we
want to test. If you change the route path, tests will break as a nice
reminder that you should probably redirect the old URL to the new
one to be nice with search engines and websites that link back to your
website.
172
We could have generated the test via the maker bundle:
$ symfony console make:functional-test Controller\\ConferenceController
phpunit.xml.dist
<phpunit>
<php>
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
<server name="SYMFONY_PHPUNIT_REMOVE" value="" />
<server name="SYMFONY_PHPUNIT_VERSION" value="8.5" />
</php>
</phpunit>
To make tests work, we must set the AKISMET_KEY secret for this test
environment:
$ APP_ENV=test symfony console secrets:set AKISMET_KEY
173
will need some stable data to run our tests and we certainly don’t want to
override what we stored in the development database.
Before being able to run the test, we need to “initialize” the test database
(create the database and migrate it):
$ APP_ENV=test symfony console doctrine:database:create
$ APP_ENV=test symfony console doctrine:migrations:migrate -n
If you now run the tests, PHPUnit won’t interact with your development
database anymore. To only run the new tests, pass the path to their class
path:
$ APP_ENV=test symfony php bin/phpunit tests/Controller/
ConferenceControllerTest.php
Note that we are setting APP_ENV explicitely even when runing PHPUnit to
let the Symfony CLI set the database name to main_test.
174
--- a/src/DataFixtures/AppFixtures.php
+++ b/src/DataFixtures/AppFixtures.php
@@ -2,6 +2,8 @@
namespace App\DataFixtures;
+use App\Entity\Comment;
+use App\Entity\Conference;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
$manager->flush();
}
When we will load the fixtures, all data will be removed; including the
admin user. To avoid that, let’s add the admin user in the fixtures:
--- a/src/DataFixtures/AppFixtures.php
+++ b/src/DataFixtures/AppFixtures.php
@@ -2,13 +2,22 @@
namespace App\DataFixtures;
175
+use App\Entity\Admin;
use App\Entity\Comment;
use App\Entity\Conference;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
+use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
If you don’t remember which service you need to use for a given task,
use the debug:autowiring with some keyword:
$ symfony console debug:autowiring encoder
176
$ APP_ENV=test symfony console doctrine:fixtures:load
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -14,4 +14,19 @@ class ConferenceControllerTest extends WebTestCase
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h2', 'Give your feedback');
}
+
+ public function testConferencePage()
+ {
+ $client = static::createClient();
+ $crawler = $client->request('GET', '/');
+
+ $this->assertCount(2, $crawler->filter('h4'));
+
+ $client->clickLink('View');
+
+ $this->assertPageTitleContains('Amsterdam');
+ $this->assertResponseIsSuccessful();
+ $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
+ $this->assertSelectorExists('div:contains("There are 1 comments")');
+ }
}
177
• We then click on the “View” link (as it cannot click on more than one
link at a time, Symfony automatically chooses the first one it finds);
• We assert the page title, the response, and the page <h2> to be sure we
are on the right page (we could also have checked for the route that
matches);
• Finally, we assert that there is 1 comment on the page. div:contains()
is not a valid CSS selector, but Symfony has some nice additions,
borrowed from jQuery.
Instead of clicking on text (i.e. View), we could have selected the link via a
CSS selector as well:
$client->click($crawler->filter('h4 + p a')->link());
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -29,4 +29,19 @@ class ConferenceControllerTest extends WebTestCase
$this->assertSelectorTextContains('h2', 'Amsterdam 2019');
$this->assertSelectorExists('div:contains("There are 1 comments")');
}
+
+ public function testCommentSubmission()
+ {
+ $client = static::createClient();
+ $client->request('GET', '/conference/amsterdam-2019');
+ $client->submitForm('Submit', [
178
+ 'comment_form[author]' => 'Fabien',
+ 'comment_form[text]' => 'Some feedback from an automated
functional test',
+ 'comment_form[email]' => '[email protected]',
+ 'comment_form[photo]' => dirname(__DIR__, 2).'/public/images/under-
construction.gif',
+ ]);
+ $this->assertResponseRedirects();
+ $client->followRedirect();
+ $this->assertSelectorExists('div:contains("There are 2 comments")');
+ }
}
To submit a form via submitForm(), find the input names thanks to the
browser DevTools or via the Symfony Profiler Form panel. Note the
clever re-use of the under construction image!
Run the tests again to check that everything is green:
$ APP_ENV=test symfony php bin/phpunit tests/Controller/
ConferenceControllerTest.php
If you want to check the result in a browser, stop the Web server and re-
run it for the test environment:
$ symfony server:stop
$ APP_ENV=test symfony server:start -d
179
••• /conference/amsterdam-2019
Makefile
180
SHELL := /bin/bash
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -15,21 +15,6 @@ class ConferenceControllerTest extends WebTestCase
$this->assertSelectorTextContains('h2', 'Give your feedback');
}
181
-
- $this->assertCount(2, $crawler->filter('h4'));
-
- $client->clickLink('View');
-
- $this->assertPageTitleContains('Amsterdam');
- $this->assertResponseIsSuccessful();
- $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
- $this->assertSelectorExists('div:contains("There are 1 comments")');
- }
-
public function testCommentSubmission()
{
$client = static::createClient();
@@ -44,4 +29,19 @@ class ConferenceControllerTest extends WebTestCase
$client->followRedirect();
$this->assertSelectorExists('div:contains("There are 2 comments")');
}
+
+ public function testConferencePage()
+ {
+ $client = static::createClient();
+ $crawler = $client->request('GET', '/');
+
+ $this->assertCount(2, $crawler->filter('h4'));
+
+ $client->clickLink('View');
+
+ $this->assertPageTitleContains('Amsterdam');
+ $this->assertResponseIsSuccessful();
+ $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
+ $this->assertSelectorExists('div:contains("There are 1 comments")');
+ }
}
You will need to confirm the execution of the recipe (as it is not an
“officially” supported bundle):
Symfony operations: 1 recipe (d7f110145ba9f62430d1ad64d57ab069)
- WARNING dama/doctrine-test-bundle (>=4.0): From github.com/symfony/
recipes-contrib:master
182
The recipe for this package comes from the "contrib" repository, which is
open to community contributions.
Review the recipe at https://github.com/symfony/recipes-contrib/tree/master/
dama/doctrine-test-bundle/4.0
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -27,6 +27,10 @@
</whitelist>
</filter>
+ <extensions>
+ <extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension" />
+ </extensions>
+
<listeners>
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
</listeners>
And done. Any changes done in tests are now automatically rolled-back
at the end of each test.
Tests should be green again:
$ make tests
183
You can then write tests that use a real Google Chrome browser with the
following changes:
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -2,13 +2,13 @@
namespace App\Tests\Controller;
-use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+use Symfony\Component\Panther\PantherTestCase;
$this->assertResponseIsSuccessful();
184
Going Further
• List of assertions defined by Symfony for functional tests;
• PHPUnit docs;
• The Faker library to generate realistic fixtures data;
• The CssSelector component docs;
• The Symfony Panther library for browser testing and web crawling
in Symfony applications;
• The Make/Makefile docs.
185
Step 18
Going Async
Checking for spam during the handling of the form submission might
lead to some problems. If the Akismet API becomes slow, our website will
also be slow for users. But even worse, if we hit a timeout or if the Akismet
API is unavailable, we might lose comments.
Ideally, we should store the submitted data without publishing it, and
immediately return a response. Checking for spam can then be done out
of band.
187
$ symfony console make:migration
--- a/migrations/Version00000000000000.php
+++ b/migrations/Version00000000000000.php
@@ -20,7 +20,9 @@ final class Version20200714155905 extends AbstractMigration
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your
needs
- $this->addSql('ALTER TABLE comment ADD state VARCHAR(255) NOT NULL');
+ $this->addSql('ALTER TABLE comment ADD state VARCHAR(255)');
+ $this->addSql("UPDATE comment SET state='published'");
+ $this->addSql('ALTER TABLE comment ALTER COLUMN state SET NOT NULL');
}
We should also make sure that, by default, the state is set to submitted:
--- a/src/Entity/Comment.php
+++ b/src/Entity/Comment.php
@@ -55,9 +55,9 @@ class Comment
private $photoFilename;
/**
- * @ORM\Column(type="string", length=255)
+ * @ORM\Column(type="string", length=255, options={"default": "submitted"})
*/
- private $state;
+ private $state = 'submitted';
188
--- a/src/Controller/Admin/CommentCrudController.php
+++ b/src/Controller/Admin/CommentCrudController.php
@@ -51,6 +51,7 @@ class CommentCrudController extends AbstractCrudController
->setLabel('Photo')
->onlyOnIndex()
;
+ yield TextField::new('state');
$createdAt = DateTimeField::new('createdAt')->setFormTypeOptions([
'html5' => true,
Don’t forget to also update the tests by setting the state of the fixtures:
--- a/src/DataFixtures/AppFixtures.php
+++ b/src/DataFixtures/AppFixtures.php
@@ -37,8 +37,16 @@ class AppFixtures extends Fixture
$comment1->setAuthor('Fabien');
$comment1->setEmail('[email protected]');
$comment1->setText('This was a great conference.');
+ $comment1->setState('published');
$manager->persist($comment1);
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -2,6 +2,8 @@
namespace App\Tests\Controller;
+use App\Repository\CommentRepository;
+use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
189
$client->submitForm('Submit', [
'comment_form[author]' => 'Fabien',
'comment_form[text]' => 'Some feedback from an automated
functional test',
- 'comment_form[email]' => '[email protected]',
+ 'comment_form[email]' => $email = '[email protected]',
'comment_form[photo]' => dirname(__DIR__, 2).'/public/images/under-
construction.gif',
]);
$this->assertResponseRedirects();
+
+ // simulate comment validation
+ $comment = self::$container->get(CommentRepository::class)-
>findOneByEmail($email);
+ $comment->setState('published');
+ self::$container->get(EntityManagerInterface::class)->flush();
+
$client->followRedirect();
$this->assertSelectorExists('div:contains("There are 2 comments")');
}
From a PHPUnit test, you can get any service from the container via
self::$container->get(); it also gives access to non-public services.
190
18.3 Coding a Message Handler
A message is a data object class that should not hold any logic. It will be
serialized to be stored in a queue, so only store “simple” serializable data.
Create the CommentMessage class:
src/Message/CommentMessage.php
namespace App\Message;
class CommentMessage
{
private $id;
private $context;
src/MessageHandler/CommentMessageHandler.php
namespace App\MessageHandler;
use App\Message\CommentMessage;
use App\Repository\CommentRepository;
use App\SpamChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
191
class CommentMessageHandler implements MessageHandlerInterface
{
private $spamChecker;
private $entityManager;
private $commentRepository;
if (2 === $this->spamChecker->getSpamScore($comment,
$message->getContext())) {
$comment->setState('spam');
} else {
$comment->setState('published');
}
$this->entityManager->flush();
}
}
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -5,14 +5,15 @@ namespace App\Controller;
use App\Entity\Comment;
use App\Entity\Conference;
use App\Form\CommentFormType;
+use App\Message\CommentMessage;
192
use App\Repository\CommentRepository;
use App\Repository\ConferenceRepository;
-use App\SpamChecker;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;
$this->entityManager->persist($comment);
+ $this->entityManager->flush();
$context = [
'user_ip' => $request->getClientIp(),
193
@@ -61,11 +65,8 @@ class ConferenceController extends AbstractController
'referrer' => $request->headers->get('referer'),
'permalink' => $request->getUri(),
];
- if (2 === $spamChecker->getSpamScore($comment, $context)) {
- throw new \RuntimeException('Blatant spam, go away!');
- }
- $this->entityManager->flush();
+ $this->bus->dispatch(new CommentMessage($comment->getId(),
$context));
--- a/src/Repository/CommentRepository.php
+++ b/src/Repository/CommentRepository.php
@@ -27,7 +27,9 @@ class CommentRepository extends ServiceEntityRepository
{
$query = $this->createQueryBuilder('c')
->andWhere('c.conference = :conference')
+ ->andWhere('c.state = :state')
->setParameter('conference', $conference)
+ ->setParameter('state', 'published')
->orderBy('c.createdAt', 'DESC')
->setMaxResults(self::PAGINATOR_PER_PAGE)
->setFirstResult($offset)
194
18.5 Going Async for Real
By default, handlers are called synchronously. To go async, you need to
explicitly configure which queue to use for each handler in the config/
packages/messenger.yaml configuration file:
--- a/.env
+++ b/.env
@@ -29,7 +29,7 @@ DATABASE_URL="postgresql://127.0.0.1:5432/
db?serverVersion=13&charset=utf8"
transports:
# https://symfony.com/doc/current/messenger.html#transport-
configuration
- # async: '%env(MESSENGER_TRANSPORT_DSN)%'
+ async:
+ dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
+ options:
+ auto_setup: false
+ use_notify: true
+ check_delayed_interval: 60000
# failed: 'doctrine://default?queue_name=failed'
# sync: 'sync://'
routing:
# Route your messages to the transports
- # 'App\Message\YourMessage': async
+ App\Message\CommentMessage: async
195
Setup PostgreSQL tables and triggers:
$ symfony console make:migration
// The worker will automatically exit once it has received a stop signal via
the messenger:stop-workers command.
196
11:30:20 INFO [http_client] Response: "200
https://80cea32be1f6.rest.akismet.com/1.1/comment-check"
11:30:20 INFO [messenger] Message App\Message\CommentMessage handled by
App\MessageHandler\CommentMessageHandler::__invoke ["message" => App\Message\
CommentMessage^ { …},"class" => "App\Message\CommentMessage","handler" => "App\
MessageHandler\CommentMessageHandler::__invoke"]
11:30:20 INFO [messenger] App\Message\CommentMessage was handled
successfully (acknowledging to transport). ["message" => App\Message\
CommentMessage^ { …},"class" => "App\Message\CommentMessage"]
The message consumer activity is logged, but you get instant feedback on
the console by passing the -vv flag. You should even be able to spot the
call to the Akismet API.
To stop the consumer, press Ctrl+C.
The --watch option tells Symfony that the command must be restarted
whenever there is a filesystem change in the config/, src/, templates/, or
vendor/ directories.
If the consumer stops working for some reason (memory limit, bug, …),
it will be restarted automatically. And if the consumer fails too fast, the
Symfony CLI will give up.
197
Logs are streamed via symfony server:log with all the other logs coming
from PHP, the web server, and the application:
$ symfony server:log
To stop a worker, stop the web server or kill the PID given by the
server:status command:
$ kill 15774
--- a/config/packages/messenger.yaml
+++ b/config/packages/messenger.yaml
@@ -1,7 +1,7 @@
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed
messages to this transport for later handling.
- # failure_transport: failed
+ failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-
configuration
198
@@ -10,7 +10,10 @@ framework:
options:
use_notify: true
check_delayed_interval: 60000
- # failed: 'doctrine://default?queue_name=failed'
+ retry_strategy:
+ max_retries: 3
+ multiplier: 2
+ failed: 'doctrine://default?queue_name=failed'
# sync: 'sync://'
routing:
--- a/.symfony.cloud.yaml
+++ b/.symfony.cloud.yaml
@@ -50,3 +50,8 @@ hooks:
set -x -e
(>&2 symfony-deploy)
+
+workers:
+ messages:
+ commands:
+ start: symfony console messenger:consume async -vv --time-
limit=3600 --memory-limit=128M
199
Like for the Symfony CLI, SymfonyCloud manages restarts and logs.
To get logs for a worker, use:
$ symfony logs --worker=messages all
Going Further
• SymfonyCasts Messenger tutorial;
• The Enterprise service bus architecture and the CQRS pattern;
• The Symfony Messenger docs;
200
Step 19
Making Decisions with a
Workflow
Having a state for a model is quite common. The comment state is only
determined by the spam checker. What if we add more decision factors?
We might want to let the website admin moderate all comments after the
spam checker. The process would be something along the lines of:
• Start with a submitted state when a comment is submitted by a user;
• Let the spam checker analyze the comment and switch the state to
either potential_spam, ham, or rejected;
• If not rejected, wait for the website admin to decide if the comment is
good enough by switching the state to published or rejected.
Implementing this logic is not too complex, but you can imagine that
adding more rules would greatly increase the complexity. Instead of
coding the logic ourselves, we can use the Symfony Workflow
Component:
201
$ symfony composer req workflow
config/packages/workflow.yaml
framework:
workflows:
comment:
type: state_machine
audit_trail:
enabled: "%kernel.debug%"
marking_store:
type: 'method'
property: 'state'
supports:
- App\Entity\Comment
initial_marking: submitted
places:
- submitted
- ham
- potential_spam
- spam
- rejected
- published
transitions:
accept:
from: submitted
to: ham
might_be_spam:
from: submitted
to: potential_spam
reject_spam:
from: submitted
to: spam
publish:
from: potential_spam
to: published
reject:
from: potential_spam
to: rejected
publish_ham:
202
from: ham
to: published
reject_ham:
from: ham
to: rejected
--- a/src/MessageHandler/CommentMessageHandler.php
+++ b/src/MessageHandler/CommentMessageHandler.php
@@ -6,19 +6,28 @@ use App\Message\CommentMessage;
use App\Repository\CommentRepository;
use App\SpamChecker;
use Doctrine\ORM\EntityManagerInterface;
+use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
203
+use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Workflow\WorkflowInterface;
- $this->entityManager->flush();
+ if ($this->workflow->can($comment, 'accept')) {
+ $score = $this->spamChecker->getSpamScore($comment, $message-
>getContext());
+ $transition = 'accept';
+ if (2 === $score) {
+ $transition = 'reject_spam';
+ } elseif (1 === $score) {
+ $transition = 'might_be_spam';
204
+ }
+ $this->workflow->apply($comment, $transition);
+ $this->entityManager->flush();
+
+ $this->bus->dispatch($message);
+ } elseif ($this->logger) {
+ $this->logger->debug('Dropping comment message', ['comment' =>
$comment->getId(), 'state' => $comment->getState()]);
+ }
}
}
--- a/src/MessageHandler/CommentMessageHandler.php
+++ b/src/MessageHandler/CommentMessageHandler.php
@@ -50,6 +50,9 @@ class CommentMessageHandler implements MessageHandlerInterface
$this->entityManager->flush();
$this->bus->dispatch($message);
+ } elseif ($this->workflow->can($comment, 'publish') || $this->workflow-
>can($comment, 'publish_ham')) {
+ $this->workflow->apply($comment, $this->workflow->can($comment,
'publish') ? 'publish' : 'publish_ham');
+ $this->entityManager->flush();
} elseif ($this->logger) {
$this->logger->debug('Dropping comment message', ['comment' =>
$comment->getId(), 'state' => $comment->getState()]);
}
205
Run symfony server:log and add a comment in the frontend to see all
transitions happening one after the other.
206
$commentStateMachine which tells you that using $commentStateMachine as
an argument name has a special meaning.
Going Further
• Workflows and State Machines and when to choose each one;
• The Symfony Workflow docs.
207
Step 20
Emailing Admins
To ensure high quality feedback, the admin must moderate all comments.
When a comment is in the ham or potential_spam state, an email should be
sent to the admin with two links: one to accept the comment and one to
reject it.
First, install the Symfony Mailer component:
$ symfony composer req mailer
--- a/config/services.yaml
+++ b/config/services.yaml
209
@@ -4,6 +4,7 @@
# Put parameters here that don't need to change on each machine where the app
is deployed
# https://symfony.com/doc/current/best_practices/
configuration.html#application-related-configuration
parameters:
+ default_admin_email: [email protected]
services:
# default configuration for services in *this* file
@@ -13,6 +14,7 @@ services:
bind:
$photoDir: "%kernel.project_dir%/public/uploads/photos"
$akismetKey: "%env(AKISMET_KEY)%"
+ $adminEmail:
"%env(string:default:default_admin_email:ADMIN_EMAIL)%"
--- a/src/MessageHandler/CommentMessageHandler.php
+++ b/src/MessageHandler/CommentMessageHandler.php
@@ -7,6 +7,8 @@ use App\Repository\CommentRepository;
use App\SpamChecker;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
+use Symfony\Bridge\Twig\Mime\NotificationEmail;
+use Symfony\Component\Mailer\MailerInterface;
210
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Workflow\WorkflowInterface;
@@ -18,15 +20,19 @@ class CommentMessageHandler implements
MessageHandlerInterface
private $commentRepository;
private $bus;
private $workflow;
+ private $mailer;
+ private $adminEmail;
private $logger;
$this->bus->dispatch($message);
} elseif ($this->workflow->can($comment, 'publish') || $this->workflow-
>can($comment, 'publish_ham')) {
- $this->workflow->apply($comment, $this->workflow->can($comment,
'publish') ? 'publish' : 'publish_ham');
- $this->entityManager->flush();
+ $this->mailer->send((new NotificationEmail())
+ ->subject('New comment posted')
+ ->htmlTemplate('emails/comment_notification.html.twig')
+ ->from($this->adminEmail)
+ ->to($this->adminEmail)
+ ->context(['comment' => $comment])
+ );
} elseif ($this->logger) {
$this->logger->debug('Dropping comment message', ['comment' =>
211
$comment->getId(), 'state' => $comment->getState()]);
}
The MailerInterface is the main entry point and allows to send() emails.
To send an email, we need a sender (the From/Sender header). Instead of
setting it explicitly on the Email instance, define it globally:
--- a/config/packages/mailer.yaml
+++ b/config/packages/mailer.yaml
@@ -1,3 +1,5 @@
framework:
mailer:
dsn: '%env(MAILER_DSN)%'
+ envelope:
+ sender: "%env(string:default:default_admin_email:ADMIN_EMAIL)%"
templates/emails/comment_notification.html.twig
{% extends '@email/default/notification/body.html.twig' %}
{% block content %}
Author: {{ comment.author }}<br />
Email: {{ comment.email }}<br />
State: {{ comment.state }}<br />
<p>
{{ comment.text }}
</p>
{% endblock %}
{% block action %}
<spacer size="16"></spacer>
<button href="{{ url('review_comment', { id: comment.id })
}}">Accept</button>
<button href="{{ url('review_comment', { id: comment.id, reject: true })
}}">Reject</button>
{% endblock %}
212
The template overrides a few blocks to customize the message of the
email and to add some links that allow the admin to accept or reject
a comment. Any route argument that is not a valid route parameter is
added as a query string item (the reject URL looks like /admin/comment/
review/42?reject=true).
The default NotificationEmail template uses Inky instead of HTML to
design emails. It helps create responsive emails that are compatible with
all popular email clients.
For maximum compatibility with email readers, the notification base
layout inlines all stylesheets (via the CSS inliner package) by default.
These two features are part of optional Twig extensions that need to be
installed:
$ symfony composer req "twig/cssinliner-extra:^3" "twig/inky-extra:^3"
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -5,6 +5,11 @@
# https://symfony.com/doc/current/best_practices/
configuration.html#application-related-configuration
parameters:
default_admin_email: [email protected]
+ default_domain: '127.0.0.1'
+ default_scheme: 'http'
213
+
+ router.request_context.host:
'%env(default:default_domain:SYMFONY_DEFAULT_ROUTE_HOST)%'
+ router.request_context.scheme:
'%env(default:default_scheme:SYMFONY_DEFAULT_ROUTE_SCHEME)%'
services:
# default configuration for services in *this* file
src/Controller/AdminController.php
namespace App\Controller;
use App\Entity\Comment;
use App\Message\CommentMessage;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Workflow\Registry;
use Twig\Environment;
214
$this->entityManager = $entityManager;
$this->bus = $bus;
}
$machine = $registry->get($comment);
if ($machine->can($comment, 'publish')) {
$transition = $accepted ? 'publish' : 'reject';
} elseif ($machine->can($comment, 'publish_ham')) {
$transition = $accepted ? 'publish_ham' : 'reject_ham';
} else {
return new Response('Comment already reviewed or not in the right
state.');
}
$machine->apply($comment, $transition);
$this->entityManager->flush();
if ($accepted) {
$this->bus->dispatch(new CommentMessage($comment->getId()));
}
return $this->render('admin/review.html.twig', [
'transition' => $transition,
'comment' => $comment,
]);
}
}
The review comment URL starts with /admin/ to protect it with the
firewall defined in a previous step. The admin needs to be authenticated
to access this resource.
Instead of creating a Response instance, we have used render(), a shortcut
method provided by the AbstractController controller base class.
When the review is done, a short template thanks the admin for their hard
work:
templates/admin/review.html.twig
{% extends 'base.html.twig' %}
215
{% block body %}
<h2>Comment reviewed, thank you!</h2>
Shut down and restart the containers to add the mail catcher:
$ docker-compose stop
$ docker-compose up -d
You must also stop the message consumer as it is not yet aware of the
mail catcher:
$ symfony console messenger:stop-workers
216
20.7 Accessing the Webmail
You can open the webmail from a terminal:
$ symfony open:local:webmail
••• /
217
••• /
Click on the email title on the interface and accept or reject the comment
as you see fit:
••• /
Check the logs with server:log if that does not work as expected.
218
20.8 Managing Long-Running Scripts
Having long-running scripts comes with behaviors that you should be
aware of. Unlike the PHP model used for HTTP where each request
starts with a clean state, the message consumer is running continuously
in the background. Each handling of a message inherits the current state,
including the memory cache. To avoid any issues with Doctrine, its entity
managers are automatically cleared after the handling of a message. You
should check if your own services need to do the same or not.
--- a/config/packages/messenger.yaml
+++ b/config/packages/messenger.yaml
@@ -19,3 +19,4 @@ framework:
routing:
# Route your messages to the transports
App\Message\CommentMessage: async
+ Symfony\Component\Mailer\Messenger\SendEmailMessage: async
219
have different worker machines handling different kind of messages. It is
flexible and up to you.
$this->assertEmailCount(1);
$event = $this->getMailerEvent(0);
$this->assertEmailIsQueued($event);
$email = $this->getMailerMessage(0);
$this->assertEmailHeaderSame($email, 'To', '[email protected]');
$this->assertEmailTextBodyContains($email, 'Bar');
$this->assertEmailAttachmentCount($email, 1);
}
220
--- a/.symfony.cloud.yaml
+++ b/.symfony.cloud.yaml
@@ -4,6 +4,7 @@ type: php:7.4
runtime:
extensions:
+ - xsl
- pdo_pgsql
- apcu
- mbstring
To be on the safe side, emails are only sent on the master branch by
default. Enable SMTP explicitly on non-master branches if you know
what you are doing:
$ symfony env:setting:set email on
Going Further
• SymfonyCasts Mailer tutorial;
• The Inky templating language docs;
• The Environment Variable Processors;
• The Symfony Framework Mailer documentation;
• The SymfonyCloud documentation about Emails.
221
Step 21
Caching for Performance
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -33,9 +33,12 @@ class ConferenceController extends AbstractController
#[Route('/', name: 'homepage')]
public function index(ConferenceRepository $conferenceRepository): Response
{
223
- return new Response($this->twig->render('conference/index.html.twig', [
+ $response = new Response($this->twig->render('conference/
index.html.twig', [
'conferences' => $conferenceRepository->findAll(),
]));
+ $response->setSharedMaxAge(3600);
+
+ return $response;
}
--- a/config/packages/framework.yaml
+++ b/config/packages/framework.yaml
@@ -15,3 +15,5 @@ framework:
#fragments: true
php_errors:
log: true
+
+ http_cache: true
224
$ curl -s -I -X GET https://127.0.0.1:8000/
HTTP/2 200
age: 0
cache-control: public, s-maxage=3600
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 08:11:57 GMT
x-content-digest:
en63cef7045fe418859d73668c2703fb1324fcc0d35b21d95369a9ed1aca48e73e
x-debug-token: 9eb25a
x-debug-token-link: https://127.0.0.1:8000/_profiler/9eb25a
x-robots-tag: noindex
x-symfony-cache: GET /: miss, store
content-length: 50978
For the very first request, the cache server tells you that it was a miss and
that it performed a store to cache the response. Check the cache-control
header to see the configured cache strategy.
For subsequent requests, the response is cached (the age has also been
updated):
HTTP/2 200
age: 143
cache-control: public, s-maxage=3600
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 08:11:57 GMT
x-content-digest:
en63cef7045fe418859d73668c2703fb1324fcc0d35b21d95369a9ed1aca48e73e
x-debug-token: 9eb25a
x-debug-token-link: https://127.0.0.1:8000/_profiler/9eb25a
x-robots-tag: noindex
x-symfony-cache: GET /: fresh
content-length: 50978
225
exact same data from the database over and over again.
We might want to cache the conference names and slugs with the
Symfony Cache, but whenever possible I like to rely on the HTTP caching
infrastructure.
When you want to cache a fragment of a page, move it outside of the
current HTTP request by creating a sub-request. ESI is a perfect match for
this use case. An ESI is a way to embed the result of an HTTP request into
another.
Create a controller that only returns the HTML fragment that displays the
conferences:
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -41,6 +41,14 @@ class ConferenceController extends AbstractController
return $response;
}
templates/conference/header.html.twig
<ul>
{% for conference in conferences %}
<li><a href="{{ path('conference', { slug: conference.slug }) }}">{{
conference }}</a></li>
{% endfor %}
</ul>
226
Time to reveal the trick! Update the Twig layout to call the controller we
have just created:
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -8,11 +8,7 @@
<body>
<header>
<h1><a href="{{ path('homepage') }}">Guestbook</a></h1>
- <ul>
- {% for conference in conferences %}
- <li><a href="{{ path('conference', { slug: conference.slug })
}}">{{ conference }}</a></li>
- {% endfor %}
- </ul>
+ {{ render(path('conference_header')) }}
<hr />
</header>
{% block body %}{% endblock %}
And voilà. Refresh the page and the website is still displaying the same.
Now, every time you hit a page in the browser, two HTTP requests are
executed, one for the header and one for the main page. You have made
performance worse. Congratulations!
The conference header HTTP call is currently done internally by
Symfony, so no HTTP round-trip is involved. This also means that there
is no way to benefit from HTTP cache headers.
Convert the call to a “real” HTTP one by using an ESI.
First, enable ESI support:
--- a/config/packages/framework.yaml
+++ b/config/packages/framework.yaml
@@ -11,7 +11,7 @@ framework:
cookie_secure: auto
cookie_samesite: lax
- #esi: true
+ esi: true
#fragments: true
227
php_errors:
log: true
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -10,7 +10,7 @@
<body>
<header>
<h1><a href="{{ path('homepage') }}">Guestbook</a></h1>
- {{ render(path('conference_header')) }}
+ {{ render_esi(path('conference_header')) }}
<hr />
</header>
{% block body %}{% endblock %}
If Symfony detects a reverse proxy that knows how to deal with ESIs,
it enables support automatically (if not, it falls back to render the sub-
request synchronously).
As the Symfony reverse proxy does support ESIs, let’s check its logs
(remove the cache first - see “Purging” below):
$ curl -s -I -X GET https://127.0.0.1:8000/
HTTP/2 200
age: 0
cache-control: must-revalidate, no-cache, private
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 08:20:05 GMT
expires: Mon, 28 Oct 2019 08:20:05 GMT
x-content-digest:
en4dd846a34dcd757eb9fd277f43220effd28c00e4117bed41af7f85700eb07f2c
x-debug-token: 719a83
x-debug-token-link: https://127.0.0.1:8000/_profiler/719a83
x-robots-tag: noindex
x-symfony-cache: GET /: miss, store; GET /conference_header: miss
content-length: 50978
228
This is not what we want though. Cache the header page for an hour,
independently of everything else:
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -44,9 +44,12 @@ class ConferenceController extends AbstractController
#[Route('/conference_header', name: 'conference_header')]
public function conferenceHeader(ConferenceRepository
$conferenceRepository): Response
{
- return new Response($this->twig->render('conference/header.html.twig',
[
+ $response = new Response($this->twig->render('conference/
header.html.twig', [
'conferences' => $conferenceRepository->findAll(),
]));
+ $response->setSharedMaxAge(3600);
+
+ return $response;
}
HTTP/2 200
age: 613
cache-control: public, s-maxage=3600
content-type: text/html; charset=UTF-8
date: Mon, 28 Oct 2019 07:31:24 GMT
x-content-digest:
en15216b0803c7851d3d07071473c9f6a3a3360c6a83ccb0e550b35d5bc484bbd2
x-debug-token: cfb0e9
x-debug-token-link: https://127.0.0.1:8000/_profiler/cfb0e9
x-robots-tag: noindex
x-symfony-cache: GET /: fresh; GET /conference_header: fresh
content-length: 50978
229
and still have the header be updated every hour.
Remove the listener as we don’t need it anymore:
$ rm src/EventSubscriber/TwigEventSubscriber.php
$ rm -rf var/cache/dev/http_cache/
This strategy does not work well if you only want to invalidate some
URLs or if you want to integrate cache invalidation in your functional
tests. Let’s add a small, admin only, HTTP endpoint to invalidate some
URLs:
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -6,8 +6,10 @@ use App\Entity\Comment;
use App\Message\CommentMessage;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Bundle\FrameworkBundle\HttpCache\HttpCache;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Workflow\Registry;
@@ -52,4 +54,17 @@ class AdminController extends AbstractController
'comment' => $comment,
]);
}
+
+ #[Route('/admin/http-cache/{uri<.*>}', methods: ['PURGE'])]
+ public function purgeHttpCache(KernelInterface $kernel, Request $request,
string $uri): Response
+ {
230
+ if ('prod' === $kernel->getEnvironment()) {
+ return new Response('KO', 400);
+ }
+
+ $store = (new class($kernel) extends HttpCache {})->getStore();
+ $store->purge($request->getSchemeAndHttpHost().'/'.$uri);
+
+ return new Response('Done');
+ }
}
The new controller has been restricted to the PURGE HTTP method. This
method is not in the HTTP standard, but it is widely used to invalidate
caches.
By default, route parameters cannot contain / as it separates URL
segments. You can override this restriction for the last route parameter,
like uri, by setting your own requirement pattern (.*).
The way we get the HttpCache instance can also look a bit strange; we are
using an anonymous class as accessing the “real” one is not possible. The
HttpCache instance wraps the real kernel, which is unaware of the cache
layer as it should be.
Invalidate the homepage and the conference header via the following
cURL calls:
$ curl -I -X PURGE -u admin:admin `symfony var:export
SYMFONY_PROJECT_DEFAULT_ROUTE_URL`/admin/http-cache/
$ curl -I -X PURGE -u admin:admin `symfony var:export
SYMFONY_PROJECT_DEFAULT_ROUTE_URL`/admin/http-cache/conference_header
231
Instead of repeating it on all routes, refactor the routes to configure the
prefix on the class itself:
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -15,6 +15,7 @@ use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Workflow\Registry;
use Twig\Environment;
+#[Route('/admin')]
class AdminController extends AbstractController
{
private $twig;
@@ -28,7 +29,7 @@ class AdminController extends AbstractController
$this->bus = $bus;
}
232
$ symfony composer req process
src/Command/StepInfoCommand.php
namespace App\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
return 0;
}
}
What if we want to cache the output for a few minutes? Use the Symfony
Cache:
$ symfony composer req cache
--- a/src/Command/StepInfoCommand.php
+++ b/src/Command/StepInfoCommand.php
@@ -6,16 +6,31 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
233
use Symfony\Component\Process\Process;
+use Symfony\Contracts\Cache\CacheInterface;
+ private $cache;
+
+ public function __construct(CacheInterface $cache)
+ {
+ $this->cache = $cache;
+
+ parent::__construct();
+ }
+
protected function execute(InputInterface $input, OutputInterface
$output): int
{
- $process = new Process(['git', 'tag', '-l', '--points-at', 'HEAD']);
- $process->mustRun();
- $output->write($process->getOutput());
+ $step = $this->cache->get('app.current_step', function ($item) {
+ $process = new Process(['git', 'tag', '-l', '--points-at',
'HEAD']);
+ $process->mustRun();
+ $item->expiresAfter(30);
+
+ return $process->getOutput();
+ });
+ $output->writeln($step);
return 0;
}
The process is now only called if the app.current_step item is not in the
cache.
234
Always measure the impact of adding a cache with a profiler tool like
Blackfire.
Refer to the step about “Performance” to learn more about how you can
use Blackfire to test your code before deploying.
--- a/.symfony/services.yaml
+++ b/.symfony/services.yaml
@@ -2,3 +2,12 @@ db:
type: postgresql:13
disk: 1024
size: S
+
+varnish:
+ type: varnish:6.0
+ relationships:
+ application: 'app:http'
+ configuration:
+ vcl: !include
+ type: string
+ path: config.vcl
--- a/.symfony/routes.yaml
+++ b/.symfony/routes.yaml
@@ -1,2 +1,2 @@
-"https://{all}/": { type: upstream, upstream: "app:http" }
+"https://{all}/": { type: upstream, upstream: "varnish:http", cache: {
enabled: false } }
"http://{all}/": { type: redirect, to: "https://{all}/" }
.symfony/config.vcl
sub vcl_recv {
235
set req.backend_hint = application.backend();
}
.symfony/config.vcl
sub vcl_recv {
set req.backend_hint = application.backend();
set req.http.Surrogate-Capability = "abc=ESI/1.0";
}
sub vcl_backend_response {
if (beresp.http.Surrogate-Control ~ "ESI/1.0") {
unset beresp.http.Surrogate-Control;
set beresp.do_esi = true;
}
}
--- a/.symfony/config.vcl
+++ b/.symfony/config.vcl
@@ -1,6 +1,13 @@
sub vcl_recv {
set req.backend_hint = application.backend();
set req.http.Surrogate-Capability = "abc=ESI/1.0";
236
+
+ if (req.method == "PURGE") {
+ if (req.http.x-purge-token != "PURGE_NOW") {
+ return(synth(405));
+ }
+ return (purge);
+ }
}
sub vcl_backend_response {
In real life, you would probably restrict by IPs instead like described in
the Varnish docs.
Purge some URLs now:
$ curl -X PURGE -H 'x-purge-token PURGE_NOW' `symfony env:urls --first`
$ curl -X PURGE -H 'x-purge-token PURGE_NOW' `symfony env:urls --
first`conference_header
The URLs looks a bit strange because the URLs returned by env:urls
already ends with /.
Going Further
• Cloudflare, the global cloud platform;
• Varnish HTTP Cache docs;
• ESI specification and ESI developer resources;
• HTTP cache validation model;
• HTTP Cache in SymfonyCloud.
237
Step 22
Styling the User Interface with
Webpack
We have spent no time on the design of the user interface. To style like
a pro, we will use a modern stack, based on Webpack. And to add a
Symfony touch and ease its integration with the application, let’s install
Webpack Encore:
$ symfony composer req encore
239
22.1 Using Sass
Instead of using plain CSS, let’s switch to Sass:
$ mv assets/styles/app.css assets/styles/app.scss
--- a/assets/app.js
+++ b/assets/app.js
@@ -6,7 +6,7 @@
*/
// any CSS you import will output into a single css file (app.css in this case)
-import './styles/app.css';
+import './styles/app.scss';
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -56,7 +56,7 @@ Encore
})
How did I know which packages to install? If we had tried to build our
assets without them, Encore would have given us a nice error message
suggesting the yarn add command needed to install dependencies to load
.scss files.
240
22.2 Leveraging Bootstrap
To start with good defaults and build a responsive website, a CSS
framework like Bootstrap can go a long way. Install it as a package:
$ yarn add bootstrap jquery popper.js bs-custom-file-input --dev
Require Bootstrap in the CSS file (we have also cleaned up the file):
--- a/assets/styles/app.scss
+++ b/assets/styles/app.scss
@@ -1,3 +1 @@
-body {
- background-color: lightgray;
-}
+@import '~bootstrap/scss/bootstrap';
--- a/assets/app.js
+++ b/assets/app.js
@@ -7,6 +7,10 @@
// any CSS you import will output into a single css file (app.css in this case)
import './styles/app.scss';
+import 'bootstrap';
+import bsCustomFileInput from 'bs-custom-file-input';
config/packages/twig.yaml
twig:
form_themes: ['bootstrap_4_layout.html.twig']
241
22.3 Styling the HTML
We are now ready to style the application. Download and expand the
archive at the root of the project:
$ php -r "copy('https://symfony.com/uploads/assets/guestbook-5.2.zip',
'guestbook-5.2.zip');"
$ unzip -o guestbook-5.2.zip
$ rm guestbook-5.2.zip
Have a look at the templates, you might learn a trick or two about Twig.
Take the time to discover the visual changes. Have a look at the new
design in a browser.
242
••• /
••• /conference/amsterdam-2019
The generated login form is now styled as well as the Maker bundle uses
Bootstrap CSS classes by default:
243
••• /login
Going Further
• Webpack docs;
• Symfony Webpack Encore docs;
• SymfonyCasts Webpack Encore tutorial.
244
Step 23
Resizing Images
--- a/config/packages/workflow.yaml
+++ b/config/packages/workflow.yaml
@@ -16,6 +16,7 @@ framework:
- potential_spam
- spam
- rejected
+ - ready
- published
transitions:
accept:
@@ -29,13 +30,16 @@ framework:
to: spam
publish:
from: potential_spam
245
- to: published
+ to: ready
reject:
from: potential_spam
to: rejected
publish_ham:
from: ham
- to: published
+ to: ready
reject_ham:
from: ham
to: rejected
+ optimize:
+ from: ready
+ to: published
246
Resizing an image can be done via the following service class:
src/ImageOptimizer.php
namespace App;
use Imagine\Gd\Imagine;
use Imagine\Image\Box;
class ImageOptimizer
{
private const MAX_WIDTH = 200;
private const MAX_HEIGHT = 150;
private $imagine;
$photo = $this->imagine->open($filename);
$photo->resize(new Box($width, $height))->save($filename);
}
}
After optimizing the photo, we store the new file in place of the original
one. You might want to keep the original image around though.
247
--- a/src/MessageHandler/CommentMessageHandler.php
+++ b/src/MessageHandler/CommentMessageHandler.php
@@ -2,6 +2,7 @@
namespace App\MessageHandler;
+use App\ImageOptimizer;
use App\Message\CommentMessage;
use App\Repository\CommentRepository;
use App\SpamChecker;
@@ -21,10 +22,12 @@ class CommentMessageHandler implements
MessageHandlerInterface
private $bus;
private $workflow;
private $mailer;
+ private $imageOptimizer;
private $adminEmail;
+ private $photoDir;
private $logger;
248
+ $this->imageOptimizer->resize($this->photoDir.'/'.$comment-
>getPhotoFilename());
+ }
+ $this->workflow->apply($comment, 'optimize');
+ $this->entityManager->flush();
} elseif ($this->logger) {
$this->logger->debug('Dropping comment message', ['comment' =>
$comment->getId(), 'state' => $comment->getState()]);
}
config/packages/services.yaml
services:
_defaults:
bind:
$photoDir: "%kernel.project_dir%/public/uploads/photos"
--- a/.symfony/services.yaml
+++ b/.symfony/services.yaml
@@ -19,3 +19,7 @@ varnish:
vcl: !include
type: string
path: config.vcl
+
+files:
+ type: network-storage:1.0
+ disk: 256
--- a/.symfony.cloud.yaml
249
+++ b/.symfony.cloud.yaml
@@ -37,7 +37,7 @@ web:
mounts:
"/var": { source: local, source_path: var }
- "/public/uploads": { source: local, source_path: uploads }
+ "/public/uploads": { source: service, service: files, source_path: uploads
}
hooks:
build: |
250
Step 24
Running Crons
--- a/src/Repository/CommentRepository.php
+++ b/src/Repository/CommentRepository.php
@@ -6,6 +6,7 @@ use App\Entity\Comment;
use App\Entity\Conference;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
+use Doctrine\ORM\QueryBuilder;
251
use Doctrine\ORM\Tools\Pagination\Paginator;
/**
@@ -16,6 +17,8 @@ use Doctrine\ORM\Tools\Pagination\Paginator;
*/
class CommentRepository extends ServiceEntityRepository
{
+ private const DAYS_BEFORE_REJECTED_REMOVAL = 7;
+
public const PAGINATOR_PER_PAGE = 2;
252
For more complex queries, it is sometimes useful to have a look at the
generated SQL statements (they can be found in the logs and in the
profiler for Web requests).
src/Command/CommentCleanupCommand.php
namespace App\Command;
use App\Repository\CommentRepository;
253
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
parent::__construct();
}
if ($input->getOption('dry-run')) {
$io->note('Dry mode enabled');
$count = $this->commentRepository->countOldRejected();
} else {
$count = $this->commentRepository->deleteOldRejected();
}
return 0;
}
}
254
and they are all accessible via symfony console. As the number of available
commands can be large, you should namespace them. By convention, the
application commands should be stored under the app namespace. Add
any number of sub-namespaces by separating them by a colon (:).
A command gets the input (arguments and options passed to the
command) and you can use the output to write to the console.
Clean up the database by running the command:
$ symfony console app:comment:cleanup
--- a/.symfony.cloud.yaml
+++ b/.symfony.cloud.yaml
@@ -52,6 +52,15 @@ hooks:
(>&2 symfony-deploy)
+crons:
+ comment_cleanup:
+ # Cleanup every night at 11.50 pm (UTC).
+ spec: '50 23 * * *'
+ cmd: |
+ if [ "$SYMFONY_BRANCH" = "master" ]; then
+ croncape symfony console app:comment:cleanup
+ fi
+
workers:
messages:
commands:
The crons section defines all cron jobs. Each cron runs according to a spec
schedule.
The croncape utility monitors the execution of the command and sends
an email to the addresses defined in the MAILTO environment variable if the
255
command returns any exit code different than 0.
Configure the MAILTO environment variable:
$ symfony var:set [email protected]
Note that crons are set up on all SymfonyCloud branches. If you don’t
want to run some on non-production environments, check the
$SYMFONY_BRANCH environment variable:
Going Further
• Cron/crontab syntax;
• Croncape repository;
• Symfony Console commands;
• The Symfony Console Cheat Sheet.
256
Step 25
Notifying by all Means
257
25.1 Sending Web Application Notifications in the
Browser
As a first step, let’s notify the users that comments are moderated directly
in the browser after their submission:
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -14,6 +14,8 @@ use Symfony\Component\HttpFoundation\File\Exception\
FileException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Notifier\Notification\Notification;
+use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Routing\Annotation\Route;
use Twig\Environment;
$this->bus->dispatch(new CommentMessage($comment->getId(),
$context));
+ if ($form->isSubmitted()) {
+ $notifier->send(new Notification('Can you check your submission?
There are some problems with it.', ['browser']));
+ }
+
258
$offset = max(0, $request->query->getInt('offset', 0));
$paginator = $commentRepository->getCommentPaginator($conference,
$offset);
--- a/templates/conference/show.html.twig
+++ b/templates/conference/show.html.twig
@@ -3,6 +3,13 @@
{% block title %}Conference Guestbook - {{ conference }}{% endblock %}
{% block body %}
+ {% for message in app.flashes('notification') %}
+ <div class="alert alert-info alert-dismissible fade show">
+ {{ message }}
+ <button type="button" class="close" data-dismiss="alert" aria-
label="Close"><span aria-hidden="true">×</span></button>
+ </div>
+ {% endfor %}
+
<h2 class="mb-5">
{{ conference }} Conference
</h2>
259
••• /conference/amsterdam-2019
260
••• /conference/amsterdam-2019
Flash messages use the HTTP session system as a storage medium. The
main consequence is that the HTTP cache is disabled as the session
system must be started to check for messages.
This is the reason why we have added the flash messages snippet in
the show.html.twig template and not in the base one as we would have
lost HTTP cache for the homepage.
261
--- a/src/MessageHandler/CommentMessageHandler.php
+++ b/src/MessageHandler/CommentMessageHandler.php
@@ -4,14 +4,14 @@ namespace App\MessageHandler;
use App\ImageOptimizer;
use App\Message\CommentMessage;
+use App\Notification\CommentReviewNotification;
use App\Repository\CommentRepository;
use App\SpamChecker;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
-use Symfony\Bridge\Twig\Mime\NotificationEmail;
-use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Workflow\WorkflowInterface;
262
$this->imageOptimizer = $imageOptimizer;
- $this->adminEmail = $adminEmail;
$this->photoDir = $photoDir;
$this->logger = $logger;
}
@@ -62,13 +60,7 @@ class CommentMessageHandler implements
MessageHandlerInterface
$this->bus->dispatch($message);
} elseif ($this->workflow->can($comment, 'publish') || $this->workflow-
>can($comment, 'publish_ham')) {
- $this->mailer->send((new NotificationEmail())
- ->subject('New comment posted')
- ->htmlTemplate('emails/comment_notification.html.twig')
- ->from($this->adminEmail)
- ->to($this->adminEmail)
- ->context(['comment' => $comment])
- );
+ $this->notifier->send(new CommentReviewNotification($comment),
...$this->notifier->getAdminRecipients());
} elseif ($this->workflow->can($comment, 'optimize')) {
if ($comment->getPhotoFilename()) {
$this->imageOptimizer->resize($this->photoDir.'/'.$comment-
>getPhotoFilename());
--- a/config/packages/notifier.yaml
+++ b/config/packages/notifier.yaml
@@ -13,4 +13,4 @@ framework:
medium: ['email']
low: ['email']
admin_recipients:
- - { email: [email protected] }
+ - { email: "%env(string:default:default_admin_email:ADMIN_EMAIL)%"
}
src/Notification/CommentReviewNotification.php
namespace App\Notification;
use App\Entity\Comment;
use Symfony\Component\Notifier\Message\EmailMessage;
263
use Symfony\Component\Notifier\Notification\EmailNotificationInterface;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
return $message;
}
}
config/packages/notifier.yaml
framework:
notifier:
channel_policy:
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
264
urgent: ['email']
high: ['email']
medium: ['email']
low: ['email']
We have talked about the browser and the email channels. Let’s see some
fancier ones.
To get started, compose the Slack DSN with a Slack access token and
the Slack channel identifier where you want to send messages:
slack://ACCESS_TOKEN@default?channel=CHANNEL.
As the access token is sensitive, store the Slack DSN in the secret store:
$ symfony console secrets:set SLACK_DSN
--- a/config/packages/notifier.yaml
+++ b/config/packages/notifier.yaml
@@ -1,7 +1,7 @@
framework:
notifier:
265
- #chatter_transports:
- # slack: '%env(SLACK_DSN)%'
+ chatter_transports:
+ slack: '%env(SLACK_DSN)%'
# telegram: '%env(TELEGRAM_DSN)%'
#texter_transports:
# twilio: '%env(TWILIO_DSN)%'
--- a/src/Notification/CommentReviewNotification.php
+++ b/src/Notification/CommentReviewNotification.php
@@ -7,6 +7,7 @@ use Symfony\Component\Notifier\Message\EmailMessage;
use Symfony\Component\Notifier\Notification\EmailNotificationInterface;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
+use Symfony\Component\Notifier\Recipient\RecipientInterface;
return $message;
}
+
+ public function getChannels(RecipientInterface $recipient): array
+ {
+ if (preg_match('{\b(great|awesome)\b}i', $this->comment->getText())) {
+ return ['email', 'chat/slack'];
+ }
+
+ $this->importance(Notification::IMPORTANCE_LOW);
+
+ return ['email'];
+ }
}
266
the default rendering of the Slack message:
--- a/src/Notification/CommentReviewNotification.php
+++ b/src/Notification/CommentReviewNotification.php
@@ -3,13 +3,18 @@
namespace App\Notification;
use App\Entity\Comment;
+use Symfony\Component\Notifier\Bridge\Slack\Block\SlackDividerBlock;
+use Symfony\Component\Notifier\Bridge\Slack\Block\SlackSectionBlock;
+use Symfony\Component\Notifier\Bridge\Slack\SlackOptions;
+use Symfony\Component\Notifier\Message\ChatMessage;
use Symfony\Component\Notifier\Message\EmailMessage;
+use Symfony\Component\Notifier\Notification\ChatNotificationInterface;
use Symfony\Component\Notifier\Notification\EmailNotificationInterface;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
use Symfony\Component\Notifier\Recipient\RecipientInterface;
267
+ )
+ );
+
+ return $message;
+ }
+
public function getChannels(RecipientInterface $recipient): array
{
if (preg_match('{\b(great|awesome)\b}i', $this->comment->getText())) {
--- a/src/Notification/CommentReviewNotification.php
+++ b/src/Notification/CommentReviewNotification.php
@@ -3,6 +3,7 @@
namespace App\Notification;
use App\Entity\Comment;
+use Symfony\Component\Notifier\Bridge\Slack\Block\SlackActionsBlock;
use Symfony\Component\Notifier\Bridge\Slack\Block\SlackDividerBlock;
use Symfony\Component\Notifier\Bridge\Slack\Block\SlackSectionBlock;
use Symfony\Component\Notifier\Bridge\Slack\SlackOptions;
@@ -17,10 +18,12 @@ use Symfony\Component\Notifier\Recipient\RecipientInterface;
class CommentReviewNotification extends Notification implements
EmailNotificationInterface, ChatNotificationInterface
{
private $comment;
+ private $reviewUrl;
268
+ ->block((new SlackActionsBlock())
+ ->button('Accept', $this->reviewUrl, 'primary')
+ ->button('Reject', $this->reviewUrl.'?reject=1', 'danger')
+ )
);
return $message;
--- a/src/MessageHandler/CommentMessageHandler.php
+++ b/src/MessageHandler/CommentMessageHandler.php
@@ -60,7 +60,8 @@ class CommentMessageHandler implements MessageHandlerInterface
$this->bus->dispatch($message);
} elseif ($this->workflow->can($comment, 'publish') || $this->workflow-
>can($comment, 'publish_ham')) {
- $this->notifier->send(new CommentReviewNotification($comment),
...$this->notifier->getAdminRecipients());
+ $notification = new CommentReviewNotification($comment, $message-
>getReviewUrl());
+ $this->notifier->send($notification, ...$this->notifier-
>getAdminRecipients());
} elseif ($this->workflow->can($comment, 'optimize')) {
if ($comment->getPhotoFilename()) {
$this->imageOptimizer->resize($this->photoDir.'/'.$comment-
>getPhotoFilename());
As you can see, the review URL should be part of the comment message,
let’s add it now:
--- a/src/Message/CommentMessage.php
+++ b/src/Message/CommentMessage.php
@@ -5,14 +5,21 @@ namespace App\Message;
class CommentMessage
{
private $id;
+ private $reviewUrl;
private $context;
269
+ $this->reviewUrl = $reviewUrl;
$this->context = $context;
}
Finally, update the controllers to generate the review URL and pass it in
the comment message constructor:
--- a/src/Controller/AdminController.php
+++ b/src/Controller/AdminController.php
@@ -12,6 +12,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Annotation\Route;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Workflow\Registry;
use Twig\Environment;
if ($accepted) {
- $this->bus->dispatch(new CommentMessage($comment->getId()));
+ $reviewUrl = $this->generateUrl('review_comment', ['id' =>
$comment->getId()], UrlGeneratorInterface::ABSOLUTE_URL);
+ $this->bus->dispatch(new CommentMessage($comment->getId(),
$reviewUrl));
}
return $this->render('admin/review.html.twig', [
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -17,6 +17,7 @@ use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\NotifierInterface;
use Symfony\Component\Routing\Annotation\Route;
+use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Environment;
270
@@ -82,7 +83,8 @@ class ConferenceController extends AbstractController
'permalink' => $request->getUri(),
];
- $this->bus->dispatch(new CommentMessage($comment->getId(),
$context));
+ $reviewUrl = $this->generateUrl('review_comment', ['id' =>
$comment->getId()], UrlGeneratorInterface::ABSOLUTE_URL);
+ $this->bus->dispatch(new CommentMessage($comment->getId(),
$reviewUrl, $context));
--- a/config/packages/messenger.yaml
+++ b/config/packages/messenger.yaml
@@ -20,3 +20,5 @@ framework:
# Route your messages to the transports
App\Message\CommentMessage: async
271
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
+ Symfony\Component\Notifier\Message\ChatMessage: async
+ Symfony\Component\Notifier\Message\SmsMessage: async
Going Further
• Symfony flash messages.
272
Step 26
Exposing an API with API
Platform
273
26.2 Exposing an API for Conferences
A few annotations on the Conference class is all we need to configure the
API:
--- a/src/Entity/Conference.php
+++ b/src/Entity/Conference.php
@@ -2,16 +2,25 @@
namespace App\Entity;
+use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\ConferenceRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\String\Slugger\SluggerInterface;
/**
* @ORM\Entity(repositoryClass=ConferenceRepository::class)
* @UniqueEntity("slug")
+ *
+ * @ApiResource(
+ *
collectionOperations={"get"={"normalization_context"={"groups"="conference:list"}}},
+ *
itemOperations={"get"={"normalization_context"={"groups"="conference:item"}}},
+ * order={"year"="DESC", "city"="ASC"},
+ * paginationEnabled=false
+ * )
*/
class Conference
{
@@ -20,21 +29,25 @@ class Conference
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
+ #[Groups(['conference:list', 'conference:item'])]
private $id;
/**
* @ORM\Column(type="string", length=255)
*/
+ #[Groups(['conference:list', 'conference:item'])]
private $city;
274
/**
* @ORM\Column(type="string", length=4)
*/
+ #[Groups(['conference:list', 'conference:item'])]
private $year;
/**
* @ORM\Column(type="boolean")
*/
+ #[Groups(['conference:list', 'conference:item'])]
private $isInternational;
/**
@@ -45,6 +58,7 @@ class Conference
/**
* @ORM\Column(type="string", length=255, unique=true)
*/
+ #[Groups(['conference:list', 'conference:item'])]
private $slug;
275
••• /api
276
••• /api
Imagine the time it would take to implement all of this from scratch!
--- a/src/Entity/Comment.php
+++ b/src/Entity/Comment.php
@@ -2,13 +2,26 @@
277
namespace App\Entity;
+use ApiPlatform\Core\Annotation\ApiFilter;
+use ApiPlatform\Core\Annotation\ApiResource;
+use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;
use App\Repository\CommentRepository;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity(repositoryClass=CommentRepository::class)
* @ORM\HasLifecycleCallbacks()
+ *
+ * @ApiResource(
+ *
collectionOperations={"get"={"normalization_context"={"groups"="comment:list"}}},
+ *
itemOperations={"get"={"normalization_context"={"groups"="comment:item"}}},
+ * order={"createdAt"="DESC"},
+ * paginationEnabled=false
+ * )
+ *
+ * @ApiFilter(SearchFilter::class, properties={"conference": "exact"})
*/
class Comment
{
@@ -17,18 +30,21 @@ class Comment
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
+ #[Groups(['comment:list', 'comment:item'])]
private $id;
/**
* @ORM\Column(type="string", length=255)
*/
#[Assert\NotBlank]
+ #[Groups(['comment:list', 'comment:item'])]
private $author;
/**
* @ORM\Column(type="text")
*/
#[Assert\NotBlank]
+ #[Groups(['comment:list', 'comment:item'])]
private $text;
/**
278
@@ -36,22 +52,26 @@ class Comment
*/
#[Assert\NotBlank]
#[Assert\Email]
+ #[Groups(['comment:list', 'comment:item'])]
private $email;
/**
* @ORM\Column(type="datetime")
*/
+ #[Groups(['comment:list', 'comment:item'])]
private $createdAt;
/**
* @ORM\ManyToOne(targetEntity=Conference::class, inversedBy="comments")
* @ORM\JoinColumn(nullable=false)
*/
+ #[Groups(['comment:list', 'comment:item'])]
private $conference;
/**
* @ORM\Column(type="string", length=255, nullable=true)
*/
+ #[Groups(['comment:list', 'comment:item'])]
private $photoFilename;
/**
src/Api/FilterPublishedCommentQueryExtension.php
namespace App\Api;
279
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\
QueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use App\Entity\Comment;
use Doctrine\ORM\QueryBuilder;
The query extension class applies its logic only for the Comment resource
and modify the Doctrine query builder to only consider comments in the
published state.
280
the next step as we will create an SPA that will have its own web server
that will call the API.
Going Further
• SymfonyCasts API Platform tutorial;
• To enable the GraphQL support, run composer require webonyx/
graphql-php, then browse to /api/graphql.
281
Step 27
Building an SPA
283
stylesheets:
$ mkdir -p spa/src spa/public spa/assets/styles
$ cp assets/styles/*.scss spa/assets/styles/
$ cd spa
.gitignore
/node_modules
/public
/yarn-error.log
# used later by Cordova
/app
webpack.config.js
const Encore = require('@symfony/webpack-encore');
const HtmlWebpackPlugin = require('html-webpack-plugin');
Encore
.setOutputPath('public/')
.setPublicPath('/')
.cleanupOutputBeforeBuild()
.addEntry('app', './src/app.js')
.enablePreactPreset()
284
.enableSingleRuntimeChunk()
.addPlugin(new HtmlWebpackPlugin({ template: 'src/index.ejs',
alwaysWriteToDisk: true }))
;
module.exports = Encore.getWebpackConfig();
src/index.ejs
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="msapplication-tap-highlight" content="no" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-
scale=1, minimum-scale=1, width=device-width" />
src/app.js
import {h, render} from 'preact';
function App() {
return (
<div>
Hello world!
</div>
)
}
285
render(<App />, document.getElementById('app'));
The last line registers the App() function on the #app element of the HTML
page.
Everything is now ready!
The --passthru flag tells the web server to pass all HTTP requests to
the public/index.html file (public/ is the web server default web root
directory). This page is managed by the Preact application and it gets the
page to render via the “browser” history.
To compile the CSS and the JavaScript files, run yarn:
$ yarn encore dev
286
••• /
src/pages/home.js
import {h} from 'preact';
287
And another for the conference page:
src/pages/conference.js
import {h} from 'preact';
--- a/src/app.js
+++ b/src/app.js
@@ -1,9 +1,22 @@
import {h, render} from 'preact';
+import {Router, Link} from 'preact-router';
+
+import Home from './pages/home';
+import Conference from './pages/conference';
function App() {
return (
<div>
- Hello world!
+ <header>
+ <Link href="/">Home</Link>
+ <br />
+ <Link href="/conference/amsterdam2019">Amsterdam 2019</Link>
+ </header>
+
+ <Router>
+ <Home path="/" />
+ <Conference path="/conference/:slug" />
+ </Router>
</div>
)
}
If you refresh the application in the browser, you can now click on the
“Home” and conference links. Note that the browser URL and the back/
288
forward buttons of your browser work as you would expect it.
Enable the Sass loader in Webpack and add a reference to the stylesheet:
--- a/src/app.js
+++ b/src/app.js
@@ -1,3 +1,5 @@
+import '../assets/styles/app.scss';
+
import {h, render} from 'preact';
import {Router, Link} from 'preact-router';
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -7,6 +7,7 @@ Encore
.cleanupOutputBeforeBuild()
.addEntry('app', './src/app.js')
.enablePreactPreset()
+ .enableSassLoader()
.enableSingleRuntimeChunk()
.addPlugin(new HtmlWebpackPlugin({ template: 'src/index.ejs',
alwaysWriteToDisk: true }))
;
--- a/src/app.js
+++ b/src/app.js
@@ -9,10 +9,20 @@ import Conference from './pages/conference';
function App() {
return (
<div>
- <header>
- <Link href="/">Home</Link>
- <br />
- <Link href="/conference/amsterdam2019">Amsterdam 2019</Link>
+ <header className="header">
+ <nav className="navbar navbar-light bg-light">
289
+ <div className="container">
+ <Link className="navbar-brand mr-4 pr-2" href="/">
+ 📙 Guestbook
+ </Link>
+ </div>
+ </nav>
+
+ <nav className="bg-light border-bottom text-center">
+ <Link className="nav-conference" href="/conference/
amsterdam2019">
+ Amsterdam 2019
+ </Link>
+ </nav>
</header>
<Router>
••• /
290
27.6 Fetching Data from the API
The Preact application structure is now finished: Preact Router handles
the page states - including the conference slug placeholder - and the main
application stylesheet is used to style the SPA.
To make the SPA dynamic, we need to fetch the data from the API via
HTTP calls.
Configure Webpack to expose the API endpoint environment variable:
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,3 +1,4 @@
+const webpack = require('webpack');
const Encore = require('@symfony/webpack-encore');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = Encore.getWebpackConfig();
src/api/api.js
function fetchCollection(path) {
return fetch(ENV_API_ENDPOINT + path).then(resp => resp.json()).then(json
=> json['hydra:member']);
}
291
export function findComments(conference) {
return fetchCollection('api/comments?conference='+conference.id);
}
--- a/src/app.js
+++ b/src/app.js
@@ -2,11 +2,23 @@ import '../assets/styles/app.scss';
function App() {
+ const [conferences, setConferences] = useState(null);
+
+ useEffect(() => {
+ findConferences().then((conferences) => setConferences(conferences));
+ }, []);
+
+ if (conferences === null) {
+ return <div className="text-center pt-5">Loading...</div>;
+ }
+
return (
<div>
<header className="header">
@@ -19,15 +31,17 @@ function App() {
</nav>
292
</header>
<Router>
- <Home path="/" />
- <Conference path="/conference/:slug" />
+ <Home path="/" conferences={conferences} />
+ <Conference path="/conference/:slug" conferences={conferences}
/>
</Router>
</div>
)
--- a/src/pages/home.js
+++ b/src/pages/home.js
@@ -1,7 +1,28 @@
import {h} from 'preact';
+import {Link} from 'preact-router';
+
+export default function Home({conferences}) {
+ if (!conferences) {
+ return <div className="p-3 text-center">No conferences yet</div>;
+ }
293
comments, again using the API; and adapt the rendering to use the API
data:
--- a/src/pages/conference.js
+++ b/src/pages/conference.js
@@ -1,7 +1,48 @@
import {h} from 'preact';
+import {findComments} from '../api/api';
+import {useState, useEffect} from 'preact/hooks';
+
+function Comment({comments}) {
+ if (comments !== null && comments.length === 0) {
+ return <div className="text-center pt-4">No comments yet</div>;
+ }
+
+ if (!comments) {
+ return <div className="text-center pt-4">Loading...</div>;
+ }
+
+ return (
+ <div className="pt-4">
+ {comments.map(comment => (
+ <div className="shadow border rounded-lg p-3 mb-4">
+ <div className="comment-img mr-3">
+ {!comment.photoFilename ? '' : (
+ <a href={ENV_API_ENDPOINT+'uploads/
photos/'+comment.photoFilename} target="_blank">
+ <img src={ENV_API_ENDPOINT+'uploads/
photos/'+comment.photoFilename} />
+ </a>
+ )}
+ </div>
+
+ <h5 className="font-weight-light mt-3
mb-0">{comment.author}</h5>
+ <div className="comment-text">{comment.text}</div>
+ </div>
+ ))}
+ </div>
+ );
+}
+
+export default function Conference({conferences, slug}) {
+ const conference = conferences.find(conference => conference.slug ===
slug);
+ const [comments, setComments] = useState(null);
+
+ useEffect(() => {
+ findComments(conference).then(comments => setComments(comments));
294
+ }, [slug]);
The SPA now needs to know the URL to our API, via the API_ENDPOINT
environment variable. Set it to the API web server URL (running in the ..
directory):
$ API_ENDPOINT=`symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL --dir=..`
yarn encore dev
295
••• /
••• /conference/amsterdam-2019
Wow! We now have a fully-functional, SPA with router and real data. We
could organize the Preact app further if we want, but it is already working
great.
296
27.7 Deploying the SPA in Production
SymfonyCloud allows to deploy multiple applications per project.
Adding another application can be done by creating a
.symfony.cloud.yaml file in any sub-directory. Create one under spa/
named spa:
.symfony.cloud.yaml
name: spa
type: php:8.0
size: S
build:
flavor: none
web:
commands:
start: sleep
locations:
"/":
root: "public"
index:
- "index.html"
scripts: false
expires: 10m
hooks:
build: |
set -x -e
yarn-install
Edit the .symfony/routes.yaml file to route the spa. subdomain to the spa
application stored in the project root directory:
297
$ cd ../
--- a/.symfony/routes.yaml
+++ b/.symfony/routes.yaml
@@ -1,2 +1,5 @@
+"https://spa.{all}/": { type: upstream, upstream: "spa:http" }
+"http://spa.{all}/": { type: redirect, to: "https://spa.{all}/" }
+
"https://{all}/": { type: upstream, upstream: "varnish:http", cache: {
enabled: false } }
"http://{all}/": { type: redirect, to: "https://{all}/" }
298
Access the SPA in a browser by specifying the application as a flag:
$ symfony open:remote --app=spa
You also need to install the Android SDK. This section only mentions
Android, but Cordova works with all mobile platforms, including iOS.
That’s all you need. You can now build the production files and move
them to Cordova:
$ API_ENDPOINT=`symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL --dir=..`
yarn encore production
$ rm -rf app/www
$ mkdir -p app/www
$ cp -R public/ app/www
299
Going Further
• The official Preact website;
• The official Cordova website.
300
Step 28
Localizing an Application
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -33,7 +33,7 @@ class ConferenceController extends AbstractController
$this->bus = $bus;
301
}
On the homepage, the locale is now set internally depending on the URL;
for instance, if you hit /fr/, $request->getLocale() returns fr.
As you will probably not be able to translate the content in all valid
locales, restrict to the ones you want to support:
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -33,7 +33,7 @@ class ConferenceController extends AbstractController
$this->bus = $bus;
}
Each route parameter can be restricted by a regular expression inside < >.
The homepage route now only matches when the _locale parameter is en
or fr. Try hitting /es/, you should have a 404 as no route matches.
As we will use the same requirement in almost all routes, let’s move it to
a container parameter:
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -7,6 +7,7 @@ parameters:
default_admin_email: [email protected]
default_domain: '127.0.0.1'
default_scheme: 'http'
+ app.supported_locales: 'en|fr'
router.request_context.host:
'%env(default:default_domain:SYMFONY_DEFAULT_ROUTE_HOST)%'
302
router.request_context.scheme:
'%env(default:default_scheme:SYMFONY_DEFAULT_ROUTE_SCHEME)%'
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -33,7 +33,7 @@ class ConferenceController extends AbstractController
$this->bus = $bus;
}
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -44,7 +44,7 @@ class ConferenceController extends AbstractController
return $response;
}
We are almost done. We don’t have a route that matches / anymore. Let’s
303
add it back and make it redirect to /en/:
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -33,6 +33,12 @@ class ConferenceController extends AbstractController
$this->bus = $bus;
}
+ #[Route('/')]
+ public function indexNoLocale(): Response
+ {
+ return $this->redirectToRoute('homepage', ['_locale' => 'en']);
+ }
+
#[Route('/{_locale<%app.supported_locales%>}/', name: 'homepage')]
public function index(ConferenceRepository $conferenceRepository): Response
{
Now that all main routes are locale aware, notice that generated URLs on
the pages take the current locale into account automatically.
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -34,6 +34,16 @@
Admin
</a>
</li>
+<li class="nav-item dropdown">
+ <a class="nav-link dropdown-toggle" href="#" id="dropdown-language"
role="button"
+ data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+ English
+ </a>
+ <div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-
language">
+ <a class="dropdown-item" href="{{ path('homepage', {_locale: 'en'})
}}">English</a>
+ <a class="dropdown-item" href="{{ path('homepage', {_locale: 'fr'})
}}">Français</a>
304
+ </div>
+</li>
</ul>
</div>
</div>
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -37,7 +37,7 @@
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="dropdown-language"
role="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
- English
+ {{ app.request.locale|locale_name(app.request.locale) }}
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-
language">
<a class="dropdown-item" href="{{ path('homepage', {_locale: 'en'})
}}">English</a>
app is a global Twig variable that gives access to the current request.
To convert the locale to a human readable string, we are using the
locale_name Twig filter.
Depending on the locale, the locale name is not always capitalized. To
capitalize sentences properly, we need a filter that is Unicode aware, as
provided by the Symfony String component and its Twig implementation:
$ symfony composer req twig/string-extra
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -37,7 +37,7 @@
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="dropdown-language"
role="button"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
305
- {{ app.request.locale|locale_name(app.request.locale) }}
+ {{ app.request.locale|locale_name(app.request.locale)|u.title }}
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-
language">
<a class="dropdown-item" href="{{ path('homepage', {_locale: 'en'})
}}">English</a>
You can now switch from French to English via the switcher and the
whole interface adapts itself quite nicely:
••• /fr/conference/amsterdam-2019
--- a/templates/base.html.twig
+++ b/templates/base.html.twig
@@ -20,7 +20,7 @@
306
<nav class="navbar navbar-expand-xl navbar-light bg-light">
<div class="container mt-4 mb-3">
<a class="navbar-brand mr-4 pr-2" href="{{
path('homepage') }}">
- 📙 Conference Guestbook
+ 📙 {{ 'Conference Guestbook'|trans }}
</a>
{% block body %}
<h2 class="mb-5">
- Give your feedback!
+ {{ 'Give your feedback!'|trans }}
</h2>
The trans Twig filter looks for a translation of the given input to the
current locale. If not found, it falls back to the default locale as configured
in config/packages/translation.yaml:
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- en
Notice that the web debug toolbar translation “tab” has turned red:
307
••• /fr/
••• /_profiler/64282d?panel=translation
308
28.4 Providing Translations
As you might have seen in config/packages/translation.yaml, translations
are stored under a translations/ root directory, which has been created
automatically for us.
Instead of creating the translation files by hand, use the
translation:update command:
This command generates a translation file (--force flag) for the fr locale
and the messages domain. The messages domain contains all application
messages excluding the ones coming from Symfony itself like validation
or security errors.
Edit the translations/messages+intl-icu.fr.xlf file and translate the
messages in French. Don’t speak French? Let me help you:
--- a/translations/messages+intl-icu.fr.xlf
+++ b/translations/messages+intl-icu.fr.xlf
@@ -7,15 +7,15 @@
<body>
<trans-unit id="LNAVleg" resname="Give your feedback!">
<source>Give your feedback!</source>
- <target>__Give your feedback!</target>
+ <target>Donnez votre avis !</target>
</trans-unit>
<trans-unit id="3Mg5pAF" resname="View">
<source>View</source>
- <target>__View</target>
+ <target>Sélectionner</target>
</trans-unit>
<trans-unit id="eOy4.6V" resname="Conference Guestbook">
<source>Conference Guestbook</source>
- <target>__Conference Guestbook</target>
+ <target>Livre d'Or pour Conferences</target>
</trans-unit>
</body>
</file>
Note that we won’t translate all templates, but feel free to do so:
309
••• /fr/
310
••• /_profiler/64282d?panel=translation
311
is wrong. Modify the template to convert the sentence to a translatable
message:
--- a/templates/conference/show.html.twig
+++ b/templates/conference/show.html.twig
@@ -44,7 +44,7 @@
</div>
</div>
{% endfor %}
- <div>There are {{ comments|length }} comments.</div>
+ <div>{{ 'nb_of_comments'|trans({count: comments|length})
}}</div>
{% if previous >= 0 %}
<a href="{{ path('conference', { slug: conference.slug,
offset: previous }) }}">Previous</a>
{% endif %}
--- a/translations/messages+intl-icu.fr.xlf
+++ b/translations/messages+intl-icu.fr.xlf
@@ -17,6 +17,10 @@
<source>Conference Guestbook</source>
<target>Livre d'Or pour Conferences</target>
</trans-unit>
+ <trans-unit id="Dg2dPd6" resname="nb_of_comments">
+ <source>nb_of_comments</source>
+ <target>{count, plural, =0 {Aucun commentaire.} =1 {1 commentaire.}
other {# commentaires.}}</target>
+ </trans-unit>
</body>
</file>
</xliff>
translations/messages+intl-icu.en.xlf
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
312
<file source-language="en" target-language="en" datatype="plaintext"
original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony"/>
</header>
<body>
<trans-unit id="maMQz7W" resname="nb_of_comments">
<source>nb_of_comments</source>
<target>{count, plural, =0 {There are no comments.} one {There is one
comment.} other {There are # comments.}}</target>
</trans-unit>
</body>
</file>
</xliff>
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -11,7 +11,7 @@ class ConferenceControllerTest extends WebTestCase
public function testIndex()
{
$client = static::createClient();
- $client->request('GET', '/');
+ $client->request('GET', '/en/');
$this->assertResponseIsSuccessful();
$this->assertSelectorTextContains('h2', 'Give your feedback');
@@ -20,7 +20,7 @@ class ConferenceControllerTest extends WebTestCase
public function testCommentSubmission()
{
$client = static::createClient();
- $client->request('GET', '/conference/amsterdam-2019');
+ $client->request('GET', '/en/conference/amsterdam-2019');
$client->submitForm('Submit', [
'comment_form[author]' => 'Fabien',
'comment_form[text]' => 'Some feedback from an automated
functional test',
@@ -41,7 +41,7 @@ class ConferenceControllerTest extends WebTestCase
public function testConferencePage()
{
313
$client = static::createClient();
- $crawler = $client->request('GET', '/');
+ $crawler = $client->request('GET', '/en/');
$this->assertCount(2, $crawler->filter('h4'));
Going Further
• Translating Messages using the ICU formatter;
• Using Twig translation filters.
314
Step 29
Managing Performance
Maybe you have already read this quotation before. But I like to cite it in
full:
We should forget about small efficiencies, say about 97% of the time:
premature optimization is the root of all evil. Yet we should not pass
up our opportunities in that critical 3%.
—Donald Knuth
315
29.1 Introducing Blackfire
Blackfire is made of several parts:
• A client that triggers profiles (the Blackfire CLI tool or a browser
extension for Google Chrome or Firefox);
• An agent that prepares and aggregates data before sending them to
blackfire.io for display;
• A PHP extension (the probe) that instruments the PHP code.
This installer downloads the Blackfire CLI Tool and then installs the PHP
probe (without enabling it) on all available PHP versions.
Enable the PHP probe for our project:
--- a/php.ini
+++ b/php.ini
@@ -7,3 +7,7 @@ session.use_strict_mode=On
realpath_cache_ttl=3600
zend.detect_unicode=Off
xdebug.file_link_format=vscode://file/%f:%l
+
+[blackfire]
+# use php_blackfire.dll on Windows
+extension=blackfire.so
The Blackfire CLI Tool needs to be configured with your personal client
credentials (to store your project profiles under your personal account).
Find them at the top of the Settings/Credentials page and execute the
following command by replacing the placeholders:
316
$ blackfire config --client-id=xxx --client-token=xxx
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -12,3 +12,8 @@ services:
mailer:
image: schickling/mailcatcher
ports: [1025, 1080]
+
+ blackfire:
+ image: blackfire/blackfire
+ env_file: .env.local
+ ports: [8707]
To communicate with the server, you need to get your personal server
credentials (these credentials identify where you want to store the profiles
– you can create one per project); they can be found at the bottom of the
Settings/Credentials page. Store them in a local .env.local file:
BLACKFIRE_SERVER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
BLACKFIRE_SERVER_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
317
29.3 Fixing a non-working Blackfire Installation
If you get an error while profiling, increase the Blackfire log level to get
more information in the logs:
--- a/php.ini
+++ b/php.ini
@@ -10,3 +10,4 @@ zend.detect_unicode=Off
[blackfire]
# use php_blackfire.dll on Windows
extension=blackfire.so
+blackfire.log_level=4
And enable the PHP probe like any other PHP extension:
--- a/.symfony.cloud.yaml
+++ b/.symfony.cloud.yaml
@@ -4,6 +4,7 @@ type: php:7.4
runtime:
extensions:
318
+ - blackfire
- xsl
- pdo_pgsql
- apcu
--- a/.symfony/config.vcl
+++ b/.symfony/config.vcl
@@ -1,3 +1,11 @@
+acl profile {
+ # Authorize the local IP address (replace with the IP found above)
+ "a.b.c.d";
+ # Authorize Blackfire servers
+ "46.51.168.2";
+ "54.75.240.245";
+}
+
sub vcl_recv {
set req.backend_hint = application.backend();
set req.http.Surrogate-Capability = "abc=ESI/1.0";
@@ -8,6 +16,16 @@ sub vcl_recv {
}
return (purge);
}
+
+ # Don't profile ESI requests
+ if (req.esi_level > 0) {
+ unset req.http.X-Blackfire-Query;
+ }
+
+ # Bypass Varnish when the profile request comes from a known IP
319
+ if (req.http.X-Blackfire-Query && client.ip ~ profile) {
+ return (pass);
+ }
}
sub vcl_backend_response {
--- a/config/packages/framework.yaml
+++ b/config/packages/framework.yaml
@@ -16,4 +16,4 @@ framework:
php_errors:
log: true
- http_cache: true
+ #http_cache: true
320
$ symfony server:prod
Don’t forget to switch it back to dev when your profiling session ends:
$ symfony server:prod --off
The blackfire curl command accepts the exact same arguments and
options as cURL.
321
Create a .blackfire.yaml file with the following content:
.blackfire.yaml
scenarios: |
#!blackfire-player
group login
visit url('/login')
submit button("Sign in")
param username "admin"
param password "admin"
expect status_code() == 302
scenario
name "Submit a comment on the Amsterdam conference page"
include login
visit url('/fr/conference/amsterdam-2019')
expect status_code() == 200
submit button("Submit")
param comment_form[author] 'Fabien'
param comment_form[email] '[email protected]'
param comment_form[text] 'Such a good conference!'
param comment_form[photo] file(fake('image', '/tmp', 400, 300,
'cats'), 'awesome-cat.jpg')
expect status_code() == 302
follow
expect status_code() == 200
expect not(body() matches "/Such a good conference/")
# Wait for the workflow to validate the submissions
wait 5000
when env != "prod"
visit url(webmail_url ~ '/messages')
expect status_code() == 200
set message_ids json("[*].id")
with message_id in message_ids
visit url(webmail_url ~ '/messages/' ~ message_id ~ '.html')
expect status_code() == 200
set accept_url css("table a").first().attr("href")
include login
visit url(accept_url)
# we don't check the status code as we can deal
# with "old" messages which do not exist anymore
# in the DB (would be a 404 then)
when env == "prod"
visit url('/admin/?entity=Comment&action=list')
expect status_code() == 200
set comment_ids css('table.table tbody tr').extract('data-id')
with id in comment_ids
visit url('/admin/comment/review/' ~ id)
322
# we don't check the status code as we scan all comments,
# including the ones already reviewed
visit url('/fr/')
wait 5000
visit url('/fr/conference/amsterdam-2019')
expect body() matches "/Such a good conference/"
Or in production:
$ ./blackfire-player.phar run --endpoint=`symfony env:urls --first`
.blackfire.yaml --variable "webmail_url=NONE" --variable="env=prod"
Blackfire scenarios can also trigger profiles for each request and run
performance tests by adding the --blackfire flag.
323
Going Further
• The Blackfire book: PHP Code Performance Explained;
• SymfonyCasts Blackfire tutorial.
324
Step 30
Discovering Symfony Internals
325
point: the public/index.php file. But what happens next? How controllers
are called?
Let’s profile the English homepage in production with Blackfire via the
Blackfire browser extension:
$ symfony remote:open
••• /
From the timeline, hover on the colored bars to have more information
about each call; you will learn a lot about how Symfony works:
• The main entry point is public/index.php;
• The Kernel::handle() method handles the request;
• It calls the HttpKernel that dispatches some events;
• The first event is RequestEvent;
• The ControllerResolver::getController() method is called to
determine which controller should be called for the incoming URL;
• The ControllerResolver::getArguments() method is called to
determine which arguments to pass to the controller (the param
converter is called);
326
• The ConferenceController::index() method is called and most of our
code is executed by this call;
• The ConferenceRepository::findAll() method gets all conferences
from the database (notice the connection to the database via
PDO::__construct());
• The Twig\Environment::render() method renders the template;
• The ResponseEvent and the FinishRequestEvent are dispatched, but it
looks like no listeners are actually registered as they seem to be really
fast to execute.
The timeline is a great way to understand how some code works; which
is very useful when you get a project developed by someone else.
Now, profile the same page from the local machine in the development
environment:
$ blackfire curl `symfony var:export SYMFONY_PROJECT_DEFAULT_ROUTE_URL`en/
Open the profile. You should be redirected to the call graph view as the
request was really quick and the timeline would be quite empty:
••• /
Do you understand what’s going on? The HTTP cache is enabled and
as such, we are profiling the Symfony HTTP cache layer. As the page
is in the cache, HttpCache\Store::restoreResponse() is getting the HTTP
327
response from its cache and the controller is never called.
Disable the cache layer in public/index.php as we did in the previous
step and try again. You can immediately see that the profile looks very
different:
••• /
Explore the timeline to learn more; switch to the call graph view to have
a different representation of the same data.
As we have just discovered, the code executed in development and
production is quite different. The development environment is slower
as the Symfony profiler tries to gather many data to ease debugging
problems. This is why you should always profile with the production
328
environment, even locally.
Some interesting experiments: profile an error page, profile the / page
(which is a redirect), or an API resource. Each profile will tell you a bit
more about how Symfony works, which class/methods are called, what is
expensive to run and what is cheap.
In production, you would see for instance the loading of a file named
.env.local.php:
••• /
329
Where does it come from? SymfonyCloud does some optimizations when
deploying a Symfony application like optimizing the Composer
autoloader (--optimize-autoloader --apcu-autoloader --classmap-
authoritative). It also optimizes environment variables defined in the
.env file (to avoid parsing the file for every request) by generating the
.env.local.php file:
330
You can also check that Xdebug is enabled for PHP-FPM by going in the
browser and clicking on the “View phpinfo()” link when hovering on the
Symfony logo of the web debug toolbar:
••• /
php.ini
[xdebug]
xdebug.mode=debug
xdebug.start_with_request=yes
.vscode/launch.json
331
{
"version": "0.2.0",
"configurations": [
{
"name": "Listen for XDebug",
"type": "php",
"request": "launch",
"port": 9003
},
{
"name": "Launch currently open script",
"type": "php",
"request": "launch",
"program": "${file}",
"cwd": "${fileDirname}",
"port": 9003
}
]
}
From Visual Studio Code and while being in your project directory, go
to the debugger and click on the green play button labelled “Listen for
Xdebug”:
If you go to the browser and refresh, the IDE should automatically take
the focus, meaning that the debugging session is ready. By default,
everything is a breakpoint, so execution stops at the first instruction. It’s
then up to you to inspect the current variables, step over the code, step
into the code, …
332
When debugging, you can uncheck the “Everything” breakpoint and
explicitely set breakpoints in your code.
If you are new to step debuggers, read the excellent tutorial for Visual
Studio Code, which explains everything visually.
Going Further
• The Xdebug Step Debugging docs;
• Debugging with Visual Studio Code.
333
Step 31
Using Redis to Store Sessions
--- a/.symfony.cloud.yaml
+++ b/.symfony.cloud.yaml
@@ -4,6 +4,7 @@ type: php:7.4
runtime:
extensions:
+ - redis
- blackfire
- xsl
- pdo_pgsql
@@ -26,6 +27,7 @@ disk: 512
relationships:
database: "db:postgresql"
+ redis: "rediscache:redis"
335
web:
locations:
--- a/.symfony/services.yaml
+++ b/.symfony/services.yaml
@@ -15,3 +15,6 @@ varnish:
files:
type: network-storage:1.0
disk: 256
+
+rediscache:
+ type: redis:5.0
--- a/config/packages/framework.yaml
+++ b/config/packages/framework.yaml
@@ -7,7 +7,7 @@ framework:
# Enables session support. Note that the session will ONLY be started if
you read or write from it.
# Remove or comment this section to explicitly disable session support.
session:
- handler_id: '%env(DATABASE_URL)%'
+ handler_id: '%env(REDIS_URL)%'
cookie_secure: auto
cookie_samesite: lax
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -17,3 +17,7 @@ services:
image: blackfire/blackfire
env_file: .env.local
ports: [8707]
+
+ redis:
+ image: redis:5-alpine
+ ports: [6379]
Isn’t it beautiful?
“Reboot” Docker to start the Redis service:
$ docker-compose stop
$ docker-compose up -d
336
$ symfony deploy
Going Further
• Redis docs.
337
Step 32
Using RabbitMQ as a Message
Broker
--- a/config/packages/messenger.yaml
+++ b/config/packages/messenger.yaml
@@ -6,7 +6,7 @@ framework:
transports:
# https://symfony.com/doc/current/messenger.html#transport-
configuration
async:
- dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
+ dsn: '%env(RABBITMQ_URL)%'
options:
339
use_notify: true
check_delayed_interval: 60000
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -21,3 +21,7 @@ services:
redis:
image: redis:5-alpine
ports: [6379]
+
+ rabbitmq:
+ image: rabbitmq:3.7-management
+ ports: [5672, 15672]
340
Or from the web debug toolbar:
••• /
••• /
341
the list of services:
--- a/.symfony/services.yaml
+++ b/.symfony/services.yaml
@@ -18,3 +18,8 @@ files:
rediscache:
type: redis:5.0
+
+queue:
+ type: rabbitmq:3.7
+ disk: 1024
+ size: S
--- a/.symfony.cloud.yaml
+++ b/.symfony.cloud.yaml
@@ -4,6 +4,7 @@ type: php:7.4
runtime:
extensions:
+ - amqp
- redis
- blackfire
- xsl
@@ -28,6 +29,7 @@ disk: 512
relationships:
database: "db:postgresql"
redis: "rediscache:redis"
+ rabbitmq: "queue:rabbitmq"
web:
locations:
When the RabbitMQ service is installed on a project, you can access its
web management interface by opening the tunnel first:
$ symfony tunnel:open
$ symfony open:remote:rabbitmq
# when done
$ symfony tunnel:close
342
Going Further
• RabbitMQ docs.
343
Step 33
What’s Next?
I hope you enjoyed the ride. I have tried to give you enough information
to help you get started faster with your Symfony projects. We have barely
scratched the surface of the Symfony world. Now, dive into the rest of
the Symfony documentation to learn more about each feature we have
discovered together.
Happy Symfony coding!
345
The more I live, the more I learn.
The more I learn, the more I realize, the less I know.
— Michel Legrand
Index
VarDumper 56 X
Varnish 235 Xdebug 330