Intelligent Ape

Occasional Code and Writings
Dokku & Symfony: Part 4

Uploading and Storing Files

2019-02-18 11 min read tutorial

Lots of applications will need to handle file uploads. We do have some storage on our Dokku server we can use. So let’s use that storage to upload some files and learn how to use Dokku’s persistent storage with our Symfony application.

We are only covering storing files on the Dokku server. This is fine when our application is only working with a small amount of files or we are just experimenting.

If our application needs a lot of data storage, then you have two primary options:

  1. Add extra storage to your Dokku server with DigitalOcean Block Storage. Not the cheapest option, but simple and you just need to tweak these instructions.
  2. Use an online storage providers like DigitalOcean Spaces, Amazon S3, Backblaze B2, or something else.

There are many tutorials on how to integrate a storage provider with Symfony and bundles that can integrate your application with those storage providers1.

Create a Branch to Work In

As always, we create a working branch for building our new feature.

git checkout -b uploading

What We Are Building

This example is about working with Dokku’s persistent storage in our Symfony application. Not about a robust and full featured file uploading experience. Use what we build here as a base for building your own applications, but do not use this code as-is.

We are going add to the homepage a list of uploaded files that can be clicked and downloaded. If we are logged in, we will also show links to delete the files, and a form to upload new files.

Where to Store Uploaded Files

Where we store our files may change between our development machine and the Dokku server. We will set up our development machine now to get uploads working and then make any necessary changes for the Dokku server later.

On our development machine, we will create an uploads directory in our public directory that we will store our uploaded files in.

This setup will make our files accessible to anyone, whether they are logged into our system or not. People could even link to our files from other sites.

.gitignore-ing Uploads

We do not want to commit our uploaded files to our repository, so the first thing we need to do is add the uploads directory to our .gitignore file in the root of our application. Add this line to the top of the .gitignore2 file:

/public/uploads

Listing Existing Files

To browse uploaded files, we need to install the Symfony Finder component.

composer req finder

Now we can update the index() method of our HomeController. We will slightly modify the example from the Symfony documentation.

You will also need to add use Symfony\Component\VarDumper\VarDumper; to the other use statements to the top of HomeController.

/**
 * @Route("/", name="home")
 */
public function index()
{
    $finder = new Finder();
    $finder->files()->in(__DIR__);

    foreach ($finder as $file) {
        // dumps the absolute path
        VarDumper::dump($file->getRealPath());
        // dumps the relative path to the file
        VarDumper::dump($file->getRelativePathname());
    }

    return $this->render('home/index.html.twig', [
        'finder' => $finder,
    ]);
}

Start up your server (sf server:run) and go to your homepage. In the profiler bar at the bottom of the page you should see that there are 4 dump statements and hovering over the icon you should see details similar to this:

finder dump

We can see that $finder->files()->in(__DIR__) is searching in our src/Controller directory. To fix this, we will create a new parameter in services.yaml that points to the uploads directory and fetch it in the HomeController.

The second problem is we have not created a public/uploads directory. To handle that, we will test if the directory exists and if not, set $finder to an empty array.

We do not want to commit the public/uploads to our repository. This makes sure that when we connect the persistent storage in Dokku later on, we do not run into any problems.

This also means that locally, we could run into a problem where we have checked out our application and the uploads directory does not exist yet. We are being careful and preparing for that situation.

In src/Controller/HomeController.php:

/**
 * @Route("/", name="home")
 */
public function index()
{
    $uploadsDir = $this->getParameter('uploads_directory');

    $finder = false === file_exists($uploadsDir)
        ? []
        : (new Finder())->files()->in($uploadsDir)
    ;

    return $this->render('home/index.html.twig', [
        'finder' => $finder,
    ]);
}

and in config/services.yaml:

# ...
parameters:
    locale: 'en'
    uploads_directory: '%kernel.project_dir%/public/uploads'

Next we will update our templates/home/index.html.twig template to list the files.

{% extends 'base.html.twig' %}

{% block title %}Hello Dokku!{% endblock %}

{% block body %}
    <h1>Hello Dokku</h1>

    <ul>
        {% for file in finder  %}
            <li>
                <a href="uploads/{{ file.relativePathName }}">
                    {{ file.relativePathName }}
                </a>
            </li>
        {% endfor %}
    </ul>
{% endblock %}

Going back to our homepage in the browser, we do not see any files listed, yet.

Uploading Files

For adding files we need:

  • a form to upload files
  • an entity to pass to the upload form
  • a method on the HomeController to handle the serving and processing the form
  • a template to display the form
  • a link to the form if the user is logged in

The Upload Entity

To work with the form we will create shortly, we need to create an Upload entity. This will work as a data transfer object instead of an entity we would persist to the database.

Create the file src/Entity/Upload.php with the contents:

<?php

namespace App\Entity;

use Symfony\Component\HttpFoundation\File\UploadedFile;

final class Upload
{
    /** @var UploadedFile */
    public $file;
}

The Form Class

We will then create a form class for our upload. Create the file src/Form/UploadFileType.php with the content:

<?php

namespace App\Form;

use App\Entity\Upload;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\FileType;

class UploadFileType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('file', FileType::class, ['label' => 'File to Upload'])
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['data_class' => Upload::class,]);
    }
}

The upload Method

In our HomeController we will add an upload method to display and process the form:

/**
 * @Route("/upload", name="upload")
 */
public function upload(Request $request)
{
    // reject users who are not logged in
    if (!$this->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
        throw new AccessDeniedHttpException('You must be logged in to upload files.');
    }

    $upload = new Upload();
    $form = $this->createForm(UploadFileType::class, $upload);
    $form->handleRequest($request);
    if ($form->isSubmitted() && $form->isValid()) {
        $file = $upload->file;

        // simplistic filename sanitize
        $fileName = preg_replace('/[^\w\.]/','-', $file->getClientOriginalName());

        // Move the file to the directory where brochures are stored
        try {
            $file->move(
                $this->getParameter('uploads_directory'),
                $fileName
            );
            $this->addFlash('success', 'File Uploaded!');
        } catch (FileException $e) {
            throw $e;
        }

        return $this->redirectToRoute('home');
    }

    return $this->render('home/upload.html.twig', [
        'form' => $form->createView(),
    ]);
}

Some things to point out in this method are:

  1. The check to make sure that the user is USER_AUTHENTICATED_REMEMBERED. This makes sure the user is logged in, either through ‘remember me’ cookie or from logging in (logging in would provide USER_AUTHENTICATED_REMEMBERED and USER_AUTHENTICATED_FULLY - we just need to test for the former).
  2. If the upload is valid, we rename the file by replacing all non-word or period characters with hyphens (using preg_replace). The actual renaming happens in the $file->move call.
  3. There is a call to $this->addFlash. We will add a helper to display flash messages when we update our base.html.twig layout in the next section.

Templates and Layouts

We need a template to display the form and we need to update our layout file with a link to the upload form. We will also add a helper to display flash messages.

The Upload Form

Create the file templates/home/upload.html.twig and add the contents:

{% extends 'base.html.twig' %}

{% block title %}Upload a File!{% endblock %}

{% block body %}
    <h1>Upload a File</h1>

    {{ form_start(form) }}
        {{ form_row(form.file) }}

        <button type="submit" class="btn btn-primary">Upload!</button>
    {{ form_end(form) }}
{% endblock %}

If you browse to this view now, it looks horrible. The form is not laid out properly at all. To fix this, we need to tell twig to use the Bootstrap form theme.

Add the following to the config/packages/twig.yaml config file:

twig:
    form_themes: ['bootstrap_4_layout.html.twig']

Now everything should look better.

Update the Layout

We need to update our templates/base.html.twig layout and add a link to the upload form. We will add the link to the nav bar at the top of the page. We will also add a twig include for _helpers/flashes.html.twig that will display any flashes we create.

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>{% block title %}Symfony ♥ Dokku{% endblock %}</title>
    {% block stylesheets %}
        {{ encore_entry_link_tags('app') }}
    {% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
    <a class="navbar-brand" href="#">Symfony ♥ Dokku</a>
    <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsExampleDefault" aria-controls="navbarsExampleDefault" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
    </button>

    <div class="collapse navbar-collapse" id="navbarsExampleDefault">
        <ul class="navbar-nav mr-auto">
            <li class="nav-item active">
                <a class="nav-link" href="{{ path('home') }}">Home</a>
            </li>
            <li class="nav-item">
                <a class="nav-link" href="{{ path('more_info') }}">More Info</a>
            </li>
            {% if app.user %}
                <li class="nav-item">
                    <a class="nav-link" href="{{ path('upload') }}">Upload</a>
                </li>
            {% endif %}
        </ul>

        <span class="navbar-text">
            {% if app.user %}
                {{ app.user.email }}
                (<a href="{{ path('logout') }}">Logout</a>)
            {% else %}
                <a href="{{ path('app_login') }}">Login</a>
            {% endif %}
        </span>
    </div>
</nav>

<main role="main" class="container">
    {% include '_helpers/flashes.html.twig' %}

    {% block body %}{% endblock %}
</main><!-- /.container -->
{% block javascripts %}
    {{ encore_entry_script_tags('app') }}
{% endblock %}
</body>
</html>

The Flashes Helper

We also need to add the templates/_helpers/flashes.html.twig file (and the _helpers directory). In the file we will put the contents:

{% for flash in app.session.flashbag.get('error') %}
    <div class="alert alert-danger">{{ flash }}</div>
{% endfor %}

{% for flash in app.session.flashbag.get('warning') %}
    <div class="alert alert-warning">{{ flash }}</div>
{% endfor %}

{% for flash in app.session.flashbag.get('success') %}
    <div class="alert alert-success">{{ flash }}</div>
{% endfor %}

{% for flash in app.session.flashbag.get('info') %}
    <div class="alert alert-info">{{ flash }}</div>
{% endfor %}

Testing Our Uploader

Visit the homepage and if you not logged in, log in to the application. We should now see an “Upload” link in the nav bar and clicking it will go to the upload form. Test uploading a file with the form. You should return to the homepage and see a link to the file you just uploaded. I uploaded a file named test file.txt and here is what I get:

Uploaded File

Notice that the space in test file has been replaced with a -. Also, clicking on link will display the uploaded file. We also have the green success flash at the top of the content area.

Now if you log out, you will still see the uploaded file and be able to click the link. However, the link to the Upload form is no longer displayed in the nav bar and if you visit /upload you will get a 403 Access Denied error (though, since we are on our local development machine, we will get a helpful Symfony Exception screen).

Everything works how we want it to. Now to delete that file.

Deleting Files

To delete the files, we will add a link next to each file that will go to a deleteUpload method in out HomeController.

The deleteUpload Method

The deleteUpload method will take the filename of the upload we want to delete and, if found, delete the file. If the file is not found, it will set an error flash. In either case it will forward us back to the home page. It should also check that we are logged in (like the upload method we just added). We will add the deleteUpload method to our HomeController:

/**
 * @Route("/delete_upload/{filename}", name="delete_upload")
 */
public function deleteUpload(string $filename): RedirectResponse
{
    // reject users who are not logged in
    if (!$this->isGranted('IS_AUTHENTICATED_REMEMBERED')) {
        throw new AccessDeniedHttpException('You must be logged in to delete files.');
    }

    $uploadDir = $this->getParameter('uploads_directory');
    $file = $uploadDir.'/'.$filename;

    if (false === file_exists($file)) {
        $this->addFlash('error', 'File does not exist.');

        return $this->redirectToRoute('home');
    }

    if (unlink($file)) {
        $this->addFlash('success', 'File deleted.');

        return $this->redirectToRoute('home');
    }
    
    $this->addFlash('error', 'File could not be deleted.');

    return $this->redirectToRoute('home');
}

Next we will update the templates/home/index.html.twig view to include the “Delete” link:

<!-- ... -->
<ul>
    {% for file in finder  %}
        <li>
            <a href="uploads/{{ file.relativePathName }}">
                {{ file.relativePathName }}
            </a>
            {% if app.user %}
                <a href="{{ path('delete_upload', {'filename': file.relativePathName}) }}" class="btn btn-outline-danger btn-sm">
                    Delete
                </a>
            {% endif %}
        </li>
    {% endfor %}
</ul>
<!-- ... -->

Now if we visit out homepage and we are logged in, we will see a “Delete” button next to each file. Clicking it will delete the file. If we are not logged in, we should not see the link, and even if we try to use the link and we are not logged in, we will get another 403 Access Denied error.

Commit Our Changes

We now have uploads working so we should commit our changes to the repository.

git add .
git commit -m 'Add uploading and deleting files.'

Dokku Setup

To get our Dokku server set up to handle uploads we need to SSH into our server and create a directory to store our files.

Remember to use your server name, not dev88.xyz.

ssh root@dev88.xyz
mkdir -p /var/lib/dokku/data/storage/sf-demo/uploads
chown -R 32767:32767 /var/lib/dokku/data/storage/sf-demo

Now we need to tell Dokku to mount that folder into our application. Run this command on the server, too.

dokku storage:mount sf-demo /var/lib/dokku/data/storage/sf-demo/uploads:/app/public/uploads

The great thing is that we are mounting to the exact same place as our dev environment looks. So we do not need to change anything in our code.

We are not limited to a single mount in our application. If needed, we could mount additional folders. For example, say we wanted a folder just to store user avatars. We could create the folder /var/lib/dokku/data/storage/sf-demo/avatars and then mount it to public/avatars with the command:

dokku storage:mount sf-demo /var/lib/dokku/data/storage/sf-demo/avatars:/app/public/avatars

Merge and Push

Now we can merge our development branch into master and push our updates to Dokku.

git checkout master
git merge uploading
git branch -D uploading
git push dokku master

Once everything finishes deploying, we can go to our site and make sure everything works.

SUCCESS!

We now have a persistent storage that will keep our uploads between deployments.

The next article will finish up by tackling several mini-topics.


  1. OneupUploaderBundle has worked well for me in the past. ↩︎

  2. gitignore documentation ↩︎

Dokku & Symfony: Part 3

Users and a Database

2019-02-18 11 min read tutorial

Now that we have a running application, we need to add a database (because… databases). For this tutorial, we will use the database to add authentication to our application using Symfony’s MakerBundle.

This series is about getting Symfony working on Dokku - not creating production ready code. Use what we build here as a base for building your own applications, but do not use this code as-is.

Installing MariaDB on Dokku

This post covers MariaDB, but you can PostgreSQL, MySQL, or any other platform Doctrine supports. When deciding what database platform to use, take a look at Dokku’s GitHub for the different database plugins the Dokku provides.

To install the MariaDB plugin we on the server we need to SSH into the Dokku server and run the following command:

sudo dokku plugin:install https://github.com/dokku/dokku-mariadb.git mariadb

That is all we need to do on the server. Back on our development computer, we need to create the database and link it to our app:

dokku mariadb:create sf-demo-db
dokku mariadb:link sf-demo-db sf-demo

Create a New Branch for Development

Before we start working on this feature, we need to create a new branch and switch to it:

git checkout -b add_users

Configuring the Development Database

We are using MariaDB on the server, so we have a couple of options on our local computer. We can

  1. Install MariaDB on our local machine
  2. Use databases in Vagrant VMs or Docker containers.
  3. Use a SQLite database.

You probably know what’s best for you and this article is not the place for that topic. Whichever you go with, make sure you create a .env.local file in the root of your app and add the following:

DATABASE_URL=<your database connection information>

Database Migrations

We are going to manage our database tables with Doctrine Migrations.

Adding Migration 0

This is completely optional, but it is something I do. Though Doctrine’s migrations do support a migrate first command, they do not support a “rebuild” or similar command. This means the down method on your first migration can never be run. The only option you have is to drop and re-create the entire database if you want to rebuild your database from scratch (which you may want to frequently for development or testing).

Create a new migration file src/Migrations/Version20010101000000.php with the content:

<?php declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
 * Empty starting migration
 */
final class Version20010101000000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Empty starting migration';
    }

    public function up(Schema $schema) : void
    {
    }

    public function down(Schema $schema) : void
    {
    }
}

With this migration in place, we can now run sf doctrine:migrations:first and roll all the way back to an empty database.

Aliases are a great way to save extra typing. For example, we aliased bin/console to sf. Another alias I use is:

alias db-reset="bin/console doctrine:migrations:migrate first && bin/console doctrine:migrations:migrate"

If I am also using Doctrine Fixtures, I change the alias to:

alias db-reset="bin/console doctrine:migrations:migrate first -n && bin/console doctrine:migrations:migrate -n && bin/console doctrine:fixtures:load -n"

Scaffolding Authentication

Symfony makes it fairly easy with the MakerBundle to add a basic User entity and authentication. We can then build out the generated code into what meets our needs.

The User Entity

First we need to create a User entity, which we will do following the instructions in the Symfony documentation.

If you are asked if you want to use Argon2i for password hashing, select no.

I have had problems with getting Argon2 password hashing working with the Dokku PHP image. Because of this, we are going to use bcrypt in our configuration.

Run the make:user generator and use the following answers:

sf make:user

    The name of the security user class (e.g. User) [User]:
    > User

    Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
    > yes

    Enter a property name that will be the unique "display" name for the user (e.g.
    email, username, uuid [email]
    > email

    Does this app need to hash/check user passwords? (yes/no) [yes]:
    > yes

    The newer Argon2i password hasher requires PHP 7.2, libsodium or paragonie/sodium_compat. Your system DOES support this algorithm.
    You should use Argon2i unless your production system will not support it.

    Use Argon2i as your password hasher (bcrypt will be used otherwise)? (yes/no) [yes]:
    > no

This will generate our User entity, repository, and update our security.yaml configuration file.

The Authentication System

Scaffolding the login authentication we will also lift directly from Symfony’s documentation, using the make:auth command with the following answers:

sf make:auth

    What style of authentication do you want? [Empty authenticator]:
     [0] Empty authenticator
     [1] Login form authenticator
    > 1

    The class name of the authenticator to create (e.g. AppCustomAuthenticator):
    > LoginFormAuthenticator

    Choose a name for the controller class (e.g. SecurityController) [SecurityController]:
    > SecurityController

After the generator creates the authentication files, we need to update the App\Security\LoginFormAuthenticator::onAuthenticationSuccess() method (in src/Security/LoginFormAuthenticator.php, around line 89).

// Replace:
   throw new \Exception('TODO: provide a valid redirect inside '.__FILE__);
// with
   return new RedirectResponse($this->urlGenerator->generate('home'));

Migrating a User Table

Though there is a make:migration command, we will not use it. The migration it creates is filled with database platform-specific code. If we ever want to change database server (MariaDB to PostgreSQL) or even use SQLite for running PHPUnit tests, the migrations would fail. Instead, we will create a migration that be run independent of platform.

Instead, we will use the DoctrineMigrationBundle’s doctrine:migrations:generate command to create an empty migration and then update it with our migration code. First, generate the migration:

sf doctrine:migrations:generate

And the content for the generated migration:

The migration I generated was src/Migrations/Version20190203003048.php and so the class name is Version20190203003048.

When using the code below, make sure to the class name generated for your migration. If you just copy and paste, you will get an error.

<?php declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
 * Create Starting User Table
 */
final class Version20190203003048 extends AbstractMigration
{
    public function getDescription() : string
    {
        return 'Starting User Table';
    }

    public function up(Schema $schema) : void
    {
        $users = $schema->createTable('user');
        $users->addColumn('id', 'integer', ['autoincrement' => true]);
        $users->addColumn('email', 'string');
        $users->addColumn('roles', 'text');
        $users->addColumn('password', 'string');
        $users->setPrimaryKey(['id']);
        $users->addUniqueIndex(['email']);
    }

    public function down(Schema $schema) : void
    {
        $schema->dropTable('user');
    }
}

We can now migrate our database and create our user table:

sf doctrine:migrations:migrate

Add User Command

Building out an entire user registration system will be the focus of a later post in this series. Instead, we will add a console command to add users manually. This will also be useful for adding our first admin user to the system. The MakerBundle has a generator for console commands and we will use it with the following answers:

sf make:command

    Choose a command name:
    > app:add-user

Open the new src/Command/AddUserCommand.php file and replace the boilerplate with the following:

<?php declare(strict_types=1);

namespace App\Command;

use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

/**
 * Manually add user to the system.
 */
final class AddUserCommand extends Command
{
    protected static $defaultName = 'app:add-user';
    /** @var UserPasswordEncoderInterface */
    protected $passwordEncoder;
    /** @var EntityManagerInterface */
    protected $entityManager;

    public function __construct(UserPasswordEncoderInterface $passwordEncoder, EntityManagerInterface $entityManager, string $name = null)
    {
        $this->passwordEncoder = $passwordEncoder;
        $this->entityManager = $entityManager;
        parent::__construct($name);
    }

    protected function configure(): void
    {
        $this
            ->setDescription('Add a user')
            ->addArgument('email', InputArgument::REQUIRED, 'Email Address')
            ->addArgument('password', InputArgument::OPTIONAL, 'Password - if empty a random password will be generated.')
            ->addOption('admin', 'a', InputOption::VALUE_NONE, 'Set the user as an admin.')
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $io = new SymfonyStyle($input, $output);
        $email = $input->getArgument('email');
        $password = $input->getArgument('password') ?: \sha1(microtime());
        $roles = ['ROLE_USER'];
        if ($input->getOption('admin')) {
            $roles[] = 'ROLE_ADMIN';
        }

        $user = new User();
        $user->setEmail($email);
        $user->setPassword($this->passwordEncoder->encodePassword($user, $password));
        $user->setRoles($roles);

        try {
            $this->entityManager->persist($user);
            $this->entityManager->flush();

            $io->success('User Added!');

            $io->table(
                ['Field', 'Info'],
                [
                    ['ID', $user->getId()],
                    ['email', $user->getEmail()],
                    ['password', $password],
                    ['roles', implode(', ', $user->getRoles())],
                ]
            );
        } catch (\Exception $exception) {
            $io->error('Could not Create User!');
            $io->error($exception->getMessage());
        }

    }
}

We can now run sf app:add-user -h and see the information for our new command.

Adding Our First User

This command is being run locally and the information is for test users. We will run this command later on the server to create our own admin account in the live application.

Run our command to create an admin user. The first argument (admin@dev88.xyz) is the email address and the second (testtest) is the password. The -a makes the user an admin.

sf app:add-user admin@dev88.xyz testtest -a

    [OK] User Added!

    ---------- -----------------------
    Field      Info
    ---------- -----------------------
    ID         1
    email      admin@dev88.xyz
    password   testtest
    roles      ROLE_USER, ROLE_ADMIN
    ---------- -----------------------

Logging In

The make:auth generator created a login page at templates/security/login.html.twig. We can clean this table up to look a little nicer.

{% extends 'base.html.twig' %}

{% block title %}Log in!{% endblock %}

{% block body %}
<div class="row">
    <div class="col-sm-6 offset-3 text-center">
        <form method="post">
            {% if error %}
                <div class="alert alert-danger">{{ error.messageKey|trans(error.messageData, 'security') }}</div>
            {% endif %}

            <h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
            <label for="inputEmail" class="sr-only">Email</label>
            <input type="email" value="{{ last_username }}" name="email" id="inputEmail" class="form-control mb-2" placeholder="Email" required autofocus>
            <label for="inputPassword" class="sr-only">Password</label>
            <input type="password" name="password" id="inputPassword" class="form-control mb-2" placeholder="Password" required>

            <input type="hidden" name="_csrf_token"
                   value="{{ csrf_token('authenticate') }}"
            >

            <div class="checkbox mb-3">
                <label>
                    <input type="checkbox" name="_remember_me"> Remember me
                </label>
            </div>

            <button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
        </form>
    </div>
</div>
{% endblock %}

Remembering the User

In the template above we included the “Remember Me” checkbox. For that to work, we need up update the config/packages/security.yaml:

security:
    encoders:
        App\Entity\User:
            algorithm: bcrypt
    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            anonymous: true
            guard:
                authenticators:
                    - App\Security\LoginFormAuthenticator
            remember_me:
                secret:   '%kernel.secret%'
                lifetime: 604800 # 1 week in seconds
                path:     /

    access_control:
         - { path: ^/admin, roles: ROLE_ADMIN }

You can now visit http://localhost:8000/login (you will need to start the server with sf server:run if you had stopped it previously) and enter the username and password to log in your test admin user. You should see the test admin’s information in the debug bar at the bottom of the page.

Logged In Admin User

Logging Out

Now that we can log in, we need a way to log out. First we need a route to our config/routes.yaml file:

logout:
    path: /logout

Instead of adding a route to our config/routes.yaml file, we could instead add a method to our src/Controller/SecurityController.php:

    /**
     * @Route("/logout", name="logout")
     */
    public function logout()
    {
    }

The only benefit to this approach is if we need to do extra work when a user logs out (such as logging the event, or clear some generated files, etc.).

Now we need to tell our security system how to handle logouts. In config/packages/security.yaml add the logout option and path to the main firewall:

        main:
            anonymous: true
            guard:
                authenticators:
                    - App\Security\LoginFormAuthenticator
            remember_me:
                secret:   '%kernel.secret%'
                lifetime: 604800 # 1 week in seconds
                path:     /
            logout:
                path: logout

Now if you visit http://localhost:8000/logout, you should be logged out.

Update our templates/base.html.twig layout with the following snippet:

<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
    <a class="navbar-brand" href="#">Symfony ♥ Dokku</a>
    <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsExampleDefault" aria-controls="navbarsExampleDefault" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
    </button>

    <div class="collapse navbar-collapse" id="navbarsExampleDefault">
        <ul class="navbar-nav mr-auto">
            <li class="nav-item active">
                <a class="nav-link" href="{{ path('home') }}">Home</a>
            </li>
            <li class="nav-item">
                <a class="nav-link" href="{{ path('more_info') }}">More Info</a>
            </li>
        </ul>

        <!-- LOGIN/LOGOUT LINKS -->
        <span class="navbar-text">
            {% if app.user %}
                {{ app.user.email }}
                (<a href="{{ path('logout') }}">Logout</a>)
            {% else %}
                <a href="{{ path('app_login') }}">Login</a>
            {% endif %}
        </span>
    </div>
</nav>

Refresh your page and you should see a login link in the upper right. Clicking it should take you to the login page and once you log in, you should see your email address and a logout link instead.

Now to get back to some Dokku stuff.

Pre- and post-deployment tasks

Next we will add an app.json file to the root of our app to use Dokku’s predeploy and postdeploy tasks. In our app.json file, we are going to add a predeploy task to migrate our database:

{
    "scripts": {
        "dokku": {
            "predeploy": "php bin/console doctrine:migrations:migrate --allow-no-migration"
        }
    }
}

The --allow-no-migration is to prevent errors if we don’t have any un-run migrations.

Commit and Merge Our Updates

Before we deploy everything, we need to merge our work into master:

git add .
git commit -m 'Add Users, logging in, and logging out'
git checkout master
git merge add_users
git branch -D add_users

And finally we can push our changes up to Dokku:

git push dokku master

This will take a while to run, but when its done, we can add a new user.

Adding User to the Live Site

To add a user, we are going to run our app:add-user command. This time, use your email and a secure password. The dokku enter command will log us into the application container on the server. Once in we can run the app:add-user command (we do have to php bin/console because we do not have our alias) and then exit the server.

dokku enter
php bin/console app:add-user <your@email> <your-password> -a
exit

You should now be able to go to your demo site (for me, that is https://sf-demo.dev88.xyz) and log in with your username and password.

SUCCESS!

We now have a site that we can log in and out of. That was a lot to get through, but it is the foundation we need to build out more features.

Next we will handle uploading files and setting up persistent storage.