Preliminary considerations
We use PHPStorm with PHP 7.2, apache installed, php-xdebug installed in Ubuntu.
Setup
We read the setup guide. A good source probably will be also Symfony 3 For Beginners.
We are on a Ubuntu 18.10 x64 VM, with a Windows 10 professional host. After installing PHPStorm, it seemed everything was set up, but creating a new project with
$ composer create-project symfony/skeleton d1
$ composer create-project symfony/website-skeleton d2
would create empty, or almost empty, projects (obviously non-working).
$ composer require symfony/requirements-checker
did not solve the problem, which was solved installing missing packages:
$ sudo apt install php libapache2-mod-php php-mbstring php-xmlrpc php-soap php-gd php-xml php-cli php-zip php-mysql php-curl
Then everything worked. We start with
$ composer create-project symfony/website-skeleton download_portal
Another thing that will be useful is the command line interface.
$ wget https://get.symfony.com/cli/installer -O - | bash
We install it globally with the adviced command
$ sudo mv /home/filippo/.symfony/bin/symfony /usr/local/bin/symfony
We run also the secuity check with this, as adviced by https://github.com/sensiolabs/security-checker#integration, by running the following from the project base directory.
$ symfony security:check
This will have to be executed periodically (or maybe as part of a final check before release).
Another useful read is the coding standard document.
Enabling Symfony plugin in PHPStorm
Just remember to do it in Settings → Language & Frameworks → PHP → Symfony. You’ll have to restart PHPStorm (manually).
Starting
$ composer bin/console server:start
This starts the server, listening on 127.0.0.1:8000. Navigation to the first page shows the Symfony web debug bar – it will be very useful. The “404” error is expected since there is no route.
We build also a first page for our self-training, following approximately the docs here. The example shows how to route using the file routes.yaml, then it mentions that you can “also” route via annotations. The best practice, according to the Symfony docs (https://symfony.com/doc/master/best_practices/controllers.html), is to route via annotations. So we do it.
The tutorial says we should execute
$ composer require annotations
and we do it. Then we create the file src/Controller/TestController.php. We don’t need the lucky numbers example. It is enough to say “hello world” here.
<?php
// src/Controller/TestController.php
namespace App\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class TestController
{
/**
* @Route("/test/test", name="test")
*/
public function test()
{
return new Response(
'<html><body>Hello world!</body></html>'
);
}
}
This works, and also the Symfony web debug bar shows that we are authenticated as an anonymous user.
The name=”test” will be needed later, keep it.
The guide says a little about the installation of packages via composer and the usage of flex, but it seems to be unnecessary to study this now. We instead try to make a template, which will be very useful later. We create a template:
{# templates/test/test.html.twig #}
<body> <h1>{{ salute }} world!</h1>
</body>
and change the controller to:
<?php
// src/Controller/TestController.php
namespace App\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
class TestController extends AbstractController
{
/**
* @Route("/test/test")
*/
public function test()
{
return $this->render('test/test.html.twig', [
'salute' => 'Greetings',
]);
}
}
Further reading
Simply read the routing docs (no need to implement anything, it is very straightforward). Then, the controller docs show things we already know about, and says the important thing that extending AbstractController provides you with the additional methods render (already used to render the template), generateUrl(), redirectToRoute() and redirect(). Fetching Services could be useful. It is also mentioned that you can generate controllers
$ php bin/console make:controller BrandNewController
and generate an entire CRUD from a Doctrine entity (which we’ll find is the ORM).
$ php bin/console make:crud Product
In the controller docs we learn also how to generate 404 errors via exceptions.
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
// generate 404
throw $this->createNotFoundException('The item does not exist');
// this exception ultimately generates a 500 status error
throw new \Exception('Something went wrong!');
In the controller we can also access the request object information, by type-hinting an argument with the Request type, the session by type-hinting an argument with the SessionInterface type:
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
// ... in the controller
public function index(SessionInterface $session, Request $request, $firstName, $lastName)
{
// access a request parameter
$page = $request->query->get('page', 1);
// stores an attribute for reuse during a later user request
$session->set('foo', 'bar');
// gets the attribute set by another controller in another request
$foobar = $session->get('foobar');
// uses a default value if the attribute doesn't exist
$filters = $session->get('filters', []);
}
Sessions can be used to store information about the user between requests. Session is enabled by default, but will only be started if you read or write from it. Session storage and other configuration can be controlled under the framework.session configuration in config/packages/framework.yaml. Stored attributes remain in the session for the remainder of that user’s session. There is a specific documentation.
You can also store special messages, called flash messages, on the user’s session. By design, flash messages are meant to be used exactly once: they vanish from the session automatically as soon as you retrieve them. This feature makes them useful for storing user notifications.
Also there are more tools for responses. Like the Request, the Response object has also a public headers property. This is a ResponseHeaderBag that has some nice methods for getting and setting response headers. The header names are normalized so that using Content-Type is equivalent to content-type or even content_type.
The only requirement for a controller is to return a Response object.
use Symfony\Component\HttpFoundation\Response;
// creates a simple Response with a 200 status code (the default)
$response = new Response('Hello '.$name, Response::HTTP_OK);
// creates a CSS-response with a 200 status code
$response = new Response('<style> ... </style>');
$response->headers->set('Content-Type', 'text/css');
// JSON - returns '{"username":"jane.doe"}' and sets the proper Content-Type header
return $this->json(['username' => 'jane.doe']);
// using the file() helper to serve a file from inside a controller:
// send the file contents and force the browser to download it
return $this->file('/path/to/some_file.pdf');
The file() helper provides some arguments to configure its behavior.
Templates
The documentation about templates starts here. It is possible to use PHP as a templating language (like any PHP page in a website), or it is possible to use Twig. It is similar to the Jinja2 templating system we already use in python – there are {{ … }} (variables, results of expressions), {% … %} (statements such as for loops), {# … #} (comments not included in the generated pages), {{ title|upper }} (filters). It is possible to extend base templates with a block replacement syntax. It is possible to template HTML and CSS. There is a function to generate URLs:
<a href="{{ path('welcome') }}">Home</a>
which refers to the route named ‘welcome’.
Remember that you can link to assets via a specific function, after installing the asset package:
$ composer require symfony/asset
and you can do
<img src="{{ asset('images/logo.png') }}" alt="Symfony!" />
<link href="{{ asset('css/blog.css') }}" rel="stylesheet" />
An advice is given about the way you link stylesheets, java libraries and so on. You define a base template where you insert things that have to be used everywhere, and add things in the extended template, using the parent() Twig function to include both the parent content and the content defined in the derived template.
{# templates/base.html.twig #}
<html>
<head>
{# ... #}
{% block stylesheets %}
<link href="{{ asset('css/main.css') }}" rel="stylesheet" />
{% endblock %}
</head>
<body>
{# ... #}
{% block javascripts %}
<script src="{{ asset('js/main.js') }}"></script>
{% endblock %}
</body>
</html>
{# templates/derived/derived.html.twig #}
{% extends 'base.html.twig' %}
{% block stylesheets %}
{{ parent() }}
<link href="{{ asset('css/specific.css') }}" rel="stylesheet" />
{% endblock %}
{# ... #}
Symfony also gives you a global app variable in Twig that can be used to access the current user, the Request and more.
Output escaping is done automatically unless explicitly disabled:
<!-- output escaping is on automatically -->
{{ description }} <!-- I <3 this product -->
<!-- disable output escaping with the raw filter -->
{{ description|raw }} <!-- I <3 this product -->
Configuration
The configuration docs are straightforward. We’ll use YAML, I think. The configuration for each package can be found in config/packages/. For instance, the framework bundle is configured in config/packages/framework.yaml. There is also a .env file which is loaded and its contents become environment variables. This is useful during development, or if setting environment variables is difficult for your deployment. When you install packages, more environment variables are added to this file. But you can also add your own.
Environment variables can be referenced in any other configuration files by using a special syntax. For example, if you install the doctrine package, then you will have an environment variable called DATABASE_URL in your .env file. This is referenced inside config/packages/doctrine.yaml.
Applications created before November 2018 had a slightly different system, involving a .env.dist file. For information about upgrading, see Nov 2018 Changes to .env & How to Update. This may be useful to remember since it means that examples made before 2018-11 can be outdated.
Some useful information we copy directly here.
The .env file is special, because it defines the values that usually change on each server. For example, the database credentials on your local development machine might be different from your workmates. The .env file should contain sensible, non-secret default values for all of your environment variables and should be commited to your repository.
To override these variables with machine-specific or sensitive values, create a .env.local file. This file is not committed to the shared repository and is only stored on your machine. In fact, the .gitignore file that comes with Symfony prevents it from being committed.
You can also create a few other .env files that will be loaded:
.env.{environment}: e.g. .env.test will be loaded in the test environment and committed to your repository.
.env.{environment}.local: e.g. .env.prod.local will be loaded in the prod environment but will not be committed to your repository.
If you decide to set real environment variables on production, the .env files are still loaded, but your real environment variables will override those values.
To learn more about how to execute and control each environment, see How to Master and Create new Environments.
Database
Our application needs a database. Symfony uses Doctrine as ORM. Installation:
$ composer require symfony/orm-pack
$ composer require symfony/maker-bundle --dev # this is only an help to generate code
We configure the DB url in the .env file. We use SQLite, at least for now.
We have to do
$ sudo apt-get install php-sqlite3
to be able to use SQLite with PHP. We change
DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name
to
DATABASE_URL=sqlite:///%kernel.project_dir%/var/data.db
Now we run
$ php bin/console doctrine:database:create
creating the sqlite database /home/filippo/repositories/download_portal/var/data.db. Note that the entire /var path is not saved in the git repository, due to the default .gitignore file.
The first entity we create is the Project for our download application. We use
$ php bin/console make:entity
The id property is created automatically, we add descriptor and human_name. Files src/Entity/Project.php and src/Repository/ProjectRepository.php are created automatically. To create the database we run
$ php bin/console make:migration
$ php bin/console doctrine:migrations:migrate
The next sections of the documentation are straightforward.
Authentication
The security documentation is the place where to start. We install the security bundle:
$ composer require symfony/security-bundle
We create an User entity.
$ php bin/console make:user
We also keep all the defaults except we use the username as unique user identifier. Files src/Entity/User.php and src/Repository/UserRepository.php are created automatically as well. Then we run again
$ php bin/console make:migration
$ php bin/console doctrine:migrations:migrate
The “User Provider” and how the passwords are encoded are already configured when using make:user. The file config/packages/security.yaml is changed automatically as well.
We skip the part about the UserFixtures, we’ll return to it later.
Security
The security system is configured in config/packages/security.yaml. The most important section is firewalls. A “firewall” is your authentication system: the configuration below it defines how your users will be able to authenticate (e.g. login form, API token, etc).
# config/packages/security.yaml
security:
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
anonymous: ~
The documentation now tells you about the Guard authenticator. However, in the building of the login forms, the guard authenticator is generated automatically, as we will see.
Looking for how to build the login system
Now we have some conflicting information. The Symfony 4.0 documentation https://symfony.com/doc/4.0/security/form_login_setup.html advice is to use FOSUserBundle. The advice has been removed as of Symfony 4.2, and indeed it seems a deprecated way of doing it (https://github.com/symfony/symfony-docs/issues/9946, https://jolicode.com/blog/do-not-use-fosuserbundle). We follow the 4.2 documentation.
$ php bin/console make:auth
And select “Login form authenticator [1]”, the name ” LoginFormAuthenticator” for the class name of the authenticator, and leave the default “SecurityController” name for the controller class.
After this, we have a login form working at http://127.0.0.1/login.
In src/LoginFormAuthenticator.php, we redirect onAuthenticationSuccess:
return new RedirectResponse($this->urlGenerator->generate('test'));
We need also some dummy users, let’s go back to the security documentation where we are told to use DoctrineFixturesBundle:
$ composer require orm-fixtures --dev
$ php bin/console make:fixtures
with class name UserFixtures. Then modify src/DataFixtures/UserFixtures.php:
<?php
namespace App\DataFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use App\Entity\User;
class UserFixtures extends Fixture
{
private $passwordEncoder;
public function __construct(UserPasswordEncoderInterface $passwordEncoder)
{
$this->passwordEncoder = $passwordEncoder;
}
public function load(ObjectManager $manager)
{
$user = new User();
$user->setPassword($this->passwordEncoder->encodePassword(
$user,'test'
));
$user->setUsername('test');
$manager->persist($user);
$manager->flush();
}
}
Create the fixture by running:
$ php bin/console doctrine:fixtures:load
We can then login at http://127.0.0.1:8000/login, being redirected at test (where we can see, in the web debug toolbar, that we are logged in as “test”).
We add a logout function also – in the symfony 4 docs, it is said to configure a route in config/routes.yaml. However we never used this, so it may be better to do as here, just for consistency. However the route option seems easier, so we’ll make an exception for the logout (since we can dispense with the controller).
config/routes.yaml:
app_logout:
path: /logout
methods: GET
config/packages/security.yaml: under security: -> firewalls -> main:
logout:
path: app_logout
# where to redirect after logout - we redirect to our test page as of now.
target: test
Admin
Our application will require an admin interface some time in the future. We investigate a little. A starting point is here. EasyAdmin seems the way to go, users say it is better documented and much easier to use, even if less powerful.
$ composer require admin
We then edit config/packages/easy_admin.yaml:
easy_admin:
site_name: 'Download portal administration'
entities:
Project:
class: App\Entity\Project
User:
class: App\Entity\User
Now we can already manage the entities, but without any authentication. Since we want to test security, we configure it for the admin. We go back to the security document.
We have roles defining what an user can do. When a user logs in, Symfony calls the getRoles() method on your User object to determine which roles this user has. In the User class that we generated earlier, the roles are an array that’s stored in the database (this has been done automatically, if it wasn’t clear), and every user is always given at least one role: ROLE_USER (this not in the DB, but in the User entity class).
We create a very small hierarchy, where there is also ROLE_ADMIN. Only ROLE_ADMIN users will be able to access the admin. The example says:
# config/packages/security.yaml
security:
# ...
role_hierarchy:
ROLE_ADMIN: ROLE_USER
ROLE_SUPER_ADMIN: [ROLE_ADMIN, ROLE_ALLOWED_TO_SWITCH]
We keep it this way, for now.
We change the UserFixture to add an “admin” and a “superadmin” user:
with class name UserFixtures. Then modify src/DataFixtures/UserFixtures.php:
<?php
namespace App\DataFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use App\Entity\User;
class UserFixtures extends Fixture
{
private $passwordEncoder;
public function __construct(UserPasswordEncoderInterface $passwordEncoder)
{
$this->passwordEncoder = $passwordEncoder;
}
public function load(ObjectManager $manager)
{
$user = new User();
$user->setUsername('test');
$user->setPassword($this->passwordEncoder->encodePassword(
$user,'test'
));
$manager->persist($user);
$user = new User();
$user->setUsername('admin');
$user->setPassword($this->passwordEncoder->encodePassword(
$user,'admin'
));
$user->setRoles(["ROLE_ADMIN"]);
$manager->persist($user);
$user = new User();
$user->setUsername('super');
$user->setPassword($this->passwordEncoder->encodePassword(
$user,'super'
));
$user->setRoles(["ROLE_SUPER_ADMIN"]);
$manager->persist($user);
$manager->flush();
}
}
Then:
$ php bin/console doctrine:fixtures:load
As usual there are more ways to secure a part of the site. You can secure using URL matching, or in the controller, or in the template. In security.yaml:
access_control:
# require ROLE_ADMIN for /admin*
- { path: ^/admin, roles: ROLE_ADMIN }
At this point, it will work – by throwing an “access denied” if the test user tries to access /admin, for example.
FOSUserBundle
The FOSUserBundle package seems ok to provide standard user management features (such as password reset via email). We install it. Executing immediately
$ composer require friendsofsymfony/user-bundle
causes an error. It is stated that “If you encounter installation errors pointing at a lack of configuration parameters, such as The child node “db_driver” at path “fos_user” must be configured, you should complete the configuration in Step 5 first and then re-run this step.” Remember this! The docs say to edit app/config/config.yml, but since we are on Symfony 4.2, we have to edit instead:
fos_user:
db_driver: orm
firewall_name: main
user_class: App\Entity\User
from_email:
address: "ndp@ndp.test" # Provide a standard email address that we will have to change later - maybe a configuration in the DB customizable by the admin?
sender_name: "ndp@ndp.test"
Note that the example configuration uses from_email: address: "%mailer_user%" which does not exist in our installation, so it would cause an error anyway. We’ll configure this later. Repeating $ composer require friendsofsymfony/user-bundle will still fail with “The service “fos_user.resetting.controller” has a dependency on a non-existent service “templating”. “. Someone says to solve by adding php to the templating engines. This is not the solution, the solution is instead to add to config/packages/framework.yaml:
# Added for FOSUserBundle
templating:
engines: ['twig']
At this point $ composer require friendsofsymfony/user-bundle works. Since we already have an User class, we have to change it in order to extend it from FOS\UserBundle\Model\User. In src/Entity/User.php:
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use FOS\UserBundle\Model\User as BaseUser;
/**
* @ORM\Entity(repositoryClass="App\Repository\UserRepository")
*/
class User extends BaseUser
{
/**
* @ORM\Id()
* @ORM\GeneratedValue()
* @ORM\Column(type="integer")
*/
protected $id;
}
everything else is provided by the FOSUser bundle.
We have to clear manually the user DB table (otherwise the migration won’t work), make a migration and run it
$ bin/console make:migration
$ bin/console doctrine:migrations:migrate
We have to change also the user fixtures /src/DataFixtures/UserFixtures.php, adding the email and enabling the accounts (otherwise we won’t be able to login due to “disabled” accounts) – for example:
$user->setEmail('test@ndp.ndp');
$user->setEnabled(true);
From now, we are leaving the “novice” realm and we continue without writing everything.