Julien Neuhart IT Architect

With Symfony, I used to build my web applications in a traditional way: the framework handles everything, from the routing to the page rendering. However nowadays web applications have complex user interactions and the previous approach does not fit well for such cases. That's where single-page application architecture comes to the rescue.



Updates:
Jun. 19th, 2019:
  • Symfony 4.3 support
  • PHP 7.3
  • Improvements from the community
Jan. 3rd, 2019:
  • Force Symfony to use environment variables instead of values from .env file
Nov. 30th, 2018:
  • Symfony 4.2 support
  • PHP image is now the v2 of docker-images-php
  • Symfony roles management added in the Vue.js application
  • Handling backend exceptions section
Oct. 10th, 2018:
  • Fixtures are now created after authentication configuration (issue #1)
  • Symfony environment variables are now directly set in the file docker-compose.yml
  • PHP analysis tools section



A single-page application (SPA) is a web application or web site that interacts with the user by dynamically rewriting the current page rather than loading entire new pages from a server. This approach avoids interruption of the user experience between successive pages, making the application behave more like a desktop application.

-- Wikipedia

In others words, it's a lot easier to keep the context (data and so on) of our application in our frontend while navigating between our pages. If you have struggled with complex forms in advanced UI, you may understand the possibilities.

In this tutorial, we'll build a very simple application where users can post messages. We'll see how to:

  • setup a development environment with Docker and Docker Compose
  • create a Vue.js SPA using a router (Vue Router), a store (Vuex) and components
  • create and test a REST API using Symfony 4 as the backend framework and consuming it with axios
  • create an authentication system which works in a SPA context (using the JSON authentication from Symfony)
All files of this tutorial are available at https://github.com/thecodingmachine/symfony-vuejs.

Development environment setup

Project structure

├── services/
│   ├── mysql/
│   │   ├── utf8mb4.cnf
├── sources/
│   ├── app/
├── .gitignore
├── .env.template
├── docker-compose.yml

This is usually the structure I use for my projects:

  • a docker-compose.yml file with Traefik (reverse-proxy), PHP/Apache, MySQL and phpMyAdmin containers
  • a .env.template which contains the shared environment variables of the containers and secrets.
  • a services folder which contains the configuration files of my containers
  • a sources/app folder with my application source code

Docker Compose

Let's start with the content of the .env.template file:

MYSQL_ROOT_PASSWORD=admin
MYSQL_DATABASE=tutorial
MYSQL_USER=foo
MYSQL_PASSWORD=bar

This file will help us to store the shared environment variables of our containers and secrets we don't want to commit.

As Docker Compose is only able to read those values from a file named .env, we have to create this file from the previous template. For instance:

$ cp .env.template .env
Don't forget to add the file .env to your .gitignore file. Only the file .env.template should be committed, with dummy values for your secrets.

Let's continue with the content of the docker-compose.yml file.

We begin by adding a reverse-proxy (Traefik here) which will redirect any incoming requests to a virtual host to the correct container:

version: '3.7'

services:

  traefik:
    image: traefik:1.7
    command: --docker --docker.exposedbydefault=false
    ports:
      - "80:80"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

Next, we add our PHP/Apache container:

  app:
    image: thecodingmachine/php:7.3-v2-apache-node10
    labels:
      - traefik.enable=true
      - traefik.backend=app
      - traefik.frontend.rule=Host:app.localhost
    environment:
      APACHE_DOCUMENT_ROOT: "public/"
      PHP_EXTENSION_XDEBUG: "1"
      PHP_INI_MEMORY_LIMIT: "1G"
      # Symfony
      APP_ENV: "dev"
      APP_SECRET: "8d2a5c935d8ef1c0e2b751147382bc75"
      DATABASE_URL: "mysql://$MYSQL_USER:$MYSQL_PASSWORD@mysql:3306/$MYSQL_DATABASE"
    volumes:
      - ./sources/app:/var/www/html:rw

We're using here an image based on the official PHP image, but more complete for our developer needs:

  • Composer with prestissimo
  • Node.js with Yarn
  • no more permission issues on Linux
  • useful environment variables (for example: enable/disable PHP extensions)
  • see here for the complete list of features

Also, we added some labels on this container. Those labels are used by our Traefik container to know on which virtual host a container is linked.

For instance, our PHP/Apache container is linked with the app.localhost virtual host.

You'll need to update your /etc/hosts file on MacOS (and the equivalent on Windows) by binding this virtual host with your localhost IP (127.0.0.1).

Good? Let's continue with our MySQL and phpMyAdmin containers:

  mysql:
    image: mysql:5.7
    environment:
      MYSQL_ROOT_PASSWORD: "$MYSQL_ROOT_PASSWORD"
      MYSQL_DATABASE: "$MYSQL_DATABASE"
      MYSQL_USER: "$MYSQL_USER"
      MYSQL_PASSWORD: "$MYSQL_PASSWORD"
    volumes:
      - mysql_data:/var/lib/mysql
      - ./services/mysql/utf8mb4.cnf:/etc/mysql/conf.d/utf8mb4.cnf:ro

  phpmyadmin:
    image: phpmyadmin/phpmyadmin:4.7
    labels:
      - traefik.enable=true
      - traefik.backend=phpmyadmin
      - traefik.frontend.rule=Host:phpadmin.app.localhost
    environment:
      PMA_HOST: mysql
      PMA_USER: "$MYSQL_USER"
      PMA_PASSWORD: "$MYSQL_PASSWORD"

volumes:

  mysql_data:
    driver: local

As you can see, in a Docker context, the hostname of your database is actually the name of the MySQL service defined in your docker-compose.yml file.

You may also notice that we mount the file located at ./services/mysql/utf8mb4.cnf in our MySQL container. It's a simple MySQL configuration file to set utf8mb4 as the default encoding of our MySQL instance:

[client]
default-character-set = utf8mb4

[mysql]
default-character-set = utf8mb4

[mysqld]
character-set-client-handshake = FALSE
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci

That's it! You may now start your stack by running:

$ docker-compose up -d
You should stop any process which uses the port 80 (like your local Apache).

And start the shell of your PHP/Apache container with:

$ docker-compose exec app bash
Next commands have to be run in the shell of your PHP/Apache container.

Symfony

Nothing special here, just some Composer magic:

$ composer create-project symfony/website-skeleton .
$ composer require symfony/apache-pack
The package symfony/apache-pack installs a .htaccess file in the public directory that contains the rewrite rules.

Once done, we have to update the environment variables used by Symfony.

By default, Symfony will look at a file named .env on a development environment.

But as we're using Docker, we have put directly those values in our docker-compose.yml file.

So let's comment the following lines in config/boostrap.php:

<?php

#use Symfony\Component\Dotenv\Dotenv;

require dirname(__DIR__).'/vendor/autoload.php';

// Load cached env vars if the .env.local.php file exists
// Run "composer dump-env prod" to create it (requires symfony/flex >=1.2)
/*if (is_array($env = @include dirname(__DIR__).'/.env.local.php')) {
    foreach ($env as $k => $v) {
        $_ENV[$k] = $_ENV[$k] ?? (isset($_SERVER[$k]) && 0 !== strpos($k, 'HTTP_') ? $_SERVER[$k] : $v);
    }
} elseif (!class_exists(Dotenv::class)) {
    throw new RuntimeException('Please run "composer require symfony/dotenv" to load the ".env" files configuring the application.');
} else {
    // load all the .env files
    (new Dotenv(false))->loadEnv(dirname(__DIR__).'/.env');
}*/

$_SERVER += $_ENV;
$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null) ?: 'dev';
$_SERVER['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? 'prod' !== $_SERVER['APP_ENV'];
$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = (int) $_SERVER['APP_DEBUG'] || filter_var($_SERVER['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN) ? '1' : '0';

For our logs, we need to tell Symfony to send them to the stderr.

Indeed, by default Symfony writes them into the folder var/log. As we're using Docker, we may send them to the Docker output:

config/packages/dev/monolog.yaml:

monolog:
    handlers:
        main:
            type: stream
            # Output logs to Docker stderr by default.
            path: "php://stderr"
            #path: "%kernel.logs_dir%/%kernel.environment%.log"
            level: debug
            channels: ["!event"]
        # uncomment to get logging in your browser
        # you may have to allow bigger header sizes in your Web server configuration
        #firephp:
        #    type: firephp
        #    level: info
        #chromephp:
        #    type: chromephp
        #    level: info
        console:
            type: console
            process_psr_3_messages: false
            channels: ["!event", "!doctrine", "!console"]

config/packages/test/monolog.yaml:

monolog:
    handlers:
        main:
            type: stream
            # Output logs to Docker stderr by default.
            path: "php://stderr"
            #path: "%kernel.logs_dir%/%kernel.environment%.log"
            level: debug
            channels: ["!event"]

config/packages/prod/monolog.yaml:

monolog:
    handlers:
        main:
            type: fingers_crossed
            action_level: error
            handler: nested
            excluded_http_codes: [404, 405]
        nested:
            type: stream
            # Output logs to Docker stderr by default.
            path: "php://stderr"
            #path: "%kernel.logs_dir%/%kernel.environment%.log"
            level: debug
        console:
            type: console
            process_psr_3_messages: false
            channels: ["!event", "!doctrine"]
        deprecation:
            type: stream
            path: "%kernel.logs_dir%/%kernel.environment%.deprecations.log"
        deprecation_filter:
            type: filter
            handler: deprecation
            max_level: info
            channels: ["php"]

PHP analysis tools

In order to make sure our code base is sane, we have to install a few tools.

Let's begin with PHP_CodeSniffer:

PHP_CodeSniffer is a set of two PHP scripts; the main phpcs script that tokenizes PHP, JavaScript and CSS files to detect violations of a defined coding standard, and a second phpcbf script to automatically correct coding standard violations. PHP_CodeSniffer is an essential development tool that ensures your code remains clean and consistent.

-- GitHub

We're not going to use JavaScript and CSS features.
$ composer require --dev squizlabs/php_codesniffer doctrine/coding-standard
The package doctrine/coding-standard adds scricts rules to PHP_CodeSniffer.

phpcs.xml.dist:

<?xml version="1.0"?>
<ruleset>
    <arg name="basepath" value="."/>
    <arg name="extensions" value="php"/>
    <arg name="parallel" value="16"/>
    <arg name="colors"/>

    <!-- Ignore warnings, show progress of the run and show sniff names -->
    <arg value="nps"/>

    <!-- Directories to be checked -->
    <file>src</file>
    <file>tests</file>

    <exclude-pattern>tests/dependencies/*</exclude-pattern>

    <!-- Include full Doctrine Coding Standard -->
    <rule ref="Doctrine">
        <exclude name="SlevomatCodingStandard.Classes.SuperfluousInterfaceNaming.SuperfluousSuffix"/>
        <exclude name="SlevomatCodingStandard.Classes.SuperfluousExceptionNaming.SuperfluousSuffix"/>
        <exclude name="SlevomatCodingStandard.Classes.SuperfluousAbstractClassNaming.SuperfluousPrefix"/>
        <exclude name="Squiz.Commenting.FunctionComment.InvalidNoReturn" />
        <exclude name="Generic.Formatting.MultipleStatementAlignment" />
    </rule>

    <!-- Do not align assignments -->
    <rule ref="Generic.Formatting.MultipleStatementAlignment">
        <severity>0</severity>
    </rule>

    <!-- Do not align comments -->
    <rule ref="Squiz.Commenting.FunctionComment.SpacingAfterParamName">
        <severity>0</severity>
    </rule>
    <rule ref="Squiz.Commenting.FunctionComment.SpacingAfterParamType">
        <severity>0</severity>
    </rule>

    <!-- Require no space before colon in return types -->
    <rule ref="SlevomatCodingStandard.TypeHints.ReturnTypeHintSpacing">
        <properties>
            <property name="spacesCountBeforeColon" value="0"/>
        </properties>
    </rule>
</ruleset>

Reference the PHP_CodeSniffer scripts in our composer.json file:

"scripts": {
    "csfix": "phpcbf --ignore=src/Migrations/**,src/Kernel.php",
    "cscheck": "phpcs --ignore=src/Migrations/**,src/Kernel.php",
    # ...

You may now run those scripts by using composer csfix and composer cscheck!

  • csfix: this script will try to fix as many errors as possible
  • cscheck: this script will checks potential errors in your code

Done? Ok, let's continue with PHPStan:

PHPStan focuses on finding errors in your code without actually running it. It catches whole classes of bugs even before you write tests for the code.

-- GitHub

$  composer require --dev phpstan/phpstan

A cool feature of PHPStan is its ability to use plugins (for additional rules).

And guess what, I know some of them which are useful!

First, TheCodingMachine strict rules (see our blog post for more details):

$ composer require --dev thecodingmachine/phpstan-strict-rules

And also Safe with its PHPStan rules:

A set of core PHP functions rewritten to throw exceptions instead of returning false when an error is encountered.

-- GitHub

$ composer require thecodingmachine/safe
$ composer require --dev thecodingmachine/phpstan-safe-rule

Let's not forget to ignore the same files as in PHP_CodeSniffer and include our custom rules in a file called phpstan.neon:

includes:
    - vendor/thecodingmachine/phpstan-strict-rules/phpstan-strict-rules.neon
    - vendor/thecodingmachine/phpstan-safe-rule/phpstan-safe-rule.neon
parameters:
    excludes_analyse:
        - %currentWorkingDirectory%/src/Migrations/*.php
        - %currentWorkingDirectory%/src/Kernel.php

Last but not least, reference the PHPStan script in our composer.json file:

"scripts": {
    "phpstan": "phpstan analyse src/ -c phpstan.neon --level=7 --no-progress -vvv --memory-limit=1024M",
    # ...

Alright we're good for this part! Every time you push your modifications to your repository, don't forget to run composer csfix && composer cscheck && composer phpstan!

Webpack

Webpack is a very useful tool for building our frontend dependencies/source code.

Symfony provides a simple abstraction for this tool which fits well in a Symfony context: Webpack Encore

$ composer require symfony/webpack-encore-bundle
$ yarn install

Those commands will create:

  • an assets folder where belongs your frontend source code
  • a webpack.config.js file with Webpack Encore instructions
  • a package.json file which lists all your frontend dependencies
  • a node_modules folder which contains your frontend dependencies source code

In order to use Vue.js, we also have to install the following dependencies:

$ yarn add vue
$ yarn add --dev vue-loader vue-template-compiler @babel/plugin-transform-runtime @babel/runtime
The packages @babel/plugin-transform-runtime and @babel/runtime will allow the use of async functions.

And update our Webpack configuration file webpack.config.js:

let Encore = require('@symfony/webpack-encore');

// Manually configure the runtime environment if not already configured yet by the "encore" command.
// It's useful when you use tools that rely on webpack.config.js file.
if (!Encore.isRuntimeEnvironmentConfigured()) {
    Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
}

Encore
    // directory where compiled assets will be stored
    .setOutputPath('public/build/')
    // public path used by the web server to access the output path
    .setPublicPath('/build')
    // only needed for CDN's or sub-directory deploy
    //.setManifestKeyPrefix('build/')

    /*
     * ENTRY CONFIG
     *
     * Add 1 entry for each "page" of your app
     * (including one that's included on every page - e.g. "app")
     *
     * Each entry will result in one JavaScript file (e.g. app.js)
     * and one CSS file (e.g. app.css) if you JavaScript imports CSS.
     */
    .addEntry('app', './assets/vue/index.js')
    //.addEntry('page1', './assets/js/page1.js')
    //.addEntry('page2', './assets/js/page2.js')

    // When enabled, Webpack "splits" your files into smaller pieces for greater optimization.
    .splitEntryChunks()

    // will require an extra script tag for runtime.js
    // but, you probably want this, unless you're building a single-page app
    //.enableSingleRuntimeChunk()
    .disableSingleRuntimeChunk()

    /*
     * FEATURE CONFIG
     *
     * Enable & configure other features below. For a full
     * list of features, see:
     * https://symfony.com/doc/current/frontend.html#adding-more-features
     */
    .cleanupOutputBeforeBuild()
    .enableBuildNotifications()
    .enableSourceMaps(!Encore.isProduction())
    // enables hashed filenames (e.g. app.abc123.css)
    .enableVersioning(Encore.isProduction())

    // enables @babel/preset-env polyfills
    .configureBabel((babelConfig) => {
        babelConfig.plugins.push('@babel/plugin-transform-runtime');
    }, {
        useBuiltIns: 'usage',
        corejs: 3
    })

    // enables Vue.js support
    .enableVueLoader()

    // enables Sass/SCSS support
    //.enableSassLoader()

    // uncomment if you use TypeScript
    //.enableTypeScriptLoader()

    // uncomment to get integrity="..." attributes on your script & link tags
    // requires WebpackEncoreBundle 1.4 or higher
    .enableIntegrityHashes()

    // uncomment if you're having problems with a jQuery plugin
    //.autoProvidejQuery()

    // uncomment if you use API Platform Admin (composer req api-admin)
    //.enableReactPreset()
    //.addEntry('admin', './assets/js/admin.js')
;

module.exports = Encore.getWebpackConfig();

Here we have:

  • replaced the entry .addEntry('app', './assets/js/app.js') with .addEntry('app', './assets/vue/index.js') (./assets/vue/index.js being the future location of our Vue.js application entry point)
  • replaced .enableSingleRuntimeChunk() with .disableSingleRuntimeChunk()
  • added .enableVueLoader()
  • uncommented .enableIntegrityHashes()
  • updated the Babel configuration with @babel/plugin-transform-runtime

In your package.json, add the following entry:

"browserslist": [
    "> 0.5%",
    "last 2 versions",
    "Firefox ESR",
    "not dead"
]

Good? Let's add ESLint for some formatting and code-quality rules:

$ yarn add --dev eslint eslint-loader eslint-plugin-vue babel-eslint

.eslintrc.json:

{
  "root": true,
  "parserOptions": {
    "ecmaVersion": 2018,
    "sourceType": "module",
    "parser": "babel-eslint"
  },
  "env": {
    "browser": true,
    "es6": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:vue/recommended"
  ],
  "rules": {
      "indent": ["error", 2]
  }
}

webpack.config.js:

// enable ESLint
.addLoader({
    enforce: 'pre',
    test: /\.(js|vue)$/,
    loader: 'eslint-loader',
    exclude: /node_modules/,
    options: {
        fix: true,
        emitError: true,
        emitWarning: true,
    },
})

package.json:

"scripts": {
    "csfix": "eslint assets/vue --ext .js,.vue, --fix"

Our development environment is now ready! ☺

In the next section, we'll create our Vue.js application and see how to serve it with Symfony.

Vue.js

Let's start by removing all folders from the assets folder. They have been created by Webpack Encore and we don't need them.

Once done, we may create our Vue.js application structure:

├── assets/
│   ├── vue/
│   │   ├── App.vue
│   │   ├── index.js
  • the index.js file is our Vue.js application entry point
  • the file App.vue is its root component

App.vue:

<template>
    <div class="container">
        <h1>Hello</h1>
    </div>
</template>

<script>
export default {
  name: "App"
};
</script>

index.js:

import Vue from "vue";
import App from "./App";

new Vue({
  components: { App },
  template: "<App/>"
}).$mount("#app");

Right now we can't use those files directly as they can't be interpreted by our browser. Thanks to Webpack Encore, we are able to build a browser comprehensive output file with:

$ yarn dev
You may also run a watcher with yarn watch which will rebuild your frontend source code on every change.

The resulting files are located at public/build.

In the templates folder, there is a file named base.html.twig: this is where we'll be referencing the previous files:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Symfony 4 with a Vue.js SPA</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    {{ encore_entry_link_tags('app') }}
</head>
<body>
    <div id="app"></div>
    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
    {{ encore_entry_script_tags('app') }}
</body>
</html>
  • <div id="app"></div> is where our frontend application will be injected
  • {{ encore_entry_script_tags('app') }} is a Symfony shortcut to find our JavaScript built assets thanks to a manifest.json file created by Webpack Encore. It reference for example a key (build/app.js) with a file path: it's very useful because on your production environment you'll run yarn build which adds a hash to your built filename (e.g. app.3290Ufd.js). This prevent issues with cached Javascript files by your users' browsers
  • {{ encore_entry_link_tags('app') }} is a Symfony shortcut to find our CSS built assets. Currently, we don't have any style in our Vue.js.
  • we've also added some Bootstrap files via CDN for quick (and dirty?) styling

For rendering our file base.html.twig, we just have to create a simple controller:

IndexController.php:

<?php 

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

final class IndexController extends AbstractController
{
    /**
     * @Route("/", name="index")
     * @return Response
     */
    public function indexAction(): Response
    {
        return $this->render('base.html.twig', []);
    }
}

If you go to app.localhost, you'll see your Hello title displayed!

But what's going on?

  1. our request is interpreted by Traefik which redirects it to our PHP/Apache container
  2. Apache serves this request to the Symfony framework which matches it with the route defined in our controller
  3. the indexAction renders the base.html.twig and serves HTML to our browser
  4. once our files Vue.js compiled code has been downloaded by our browser, our Vue.js application is bootstrapped and it injects the App component into the tag <div id="app"></div>

Quite simple, no? ☺

But this tutorial is about single-page application: our Vue.js application should handle the routing between our pages.

I'll explain how to do that in the next section.

Vue.js routing

Let's make our first page by creating the Home component:

├── assets/
│   ├── vue/
│   │   ├── views/
│   │   │   ├── Home.vue

Home.vue:

<template>
  <div>
    <div class="row col">
      <h1>Homepage</h1>
    </div>

    <div class="row col">
      <p>This is the homepage of our Vue.js application.</p>
    </div>
  </div>
</template>

<script>
export default {
  name: "Home"
};
</script>

To make Vue.js render our Home component, we also have to install vue-router:

$ yarn add vue-router

And create our router:

├── assets/
│   ├── vue/
│   │   ├── router/
│   │   │   ├── index.js

index.js:

import Vue from "vue";
import VueRouter from "vue-router";
import Home from "../views/Home";

Vue.use(VueRouter);

export default new VueRouter({
  mode: "history",
  routes: [
    { path: "/home", component: Home },
    { path: "*", redirect: "/home" }
  ]
});
  • with mode: 'history', our URLs will look normal (e.g. http://app.localhost/home) instead of the default mode which uses the URL hash to simulate a full URL to prevent page reloading on URL changes
  • we define a root path (e.g. /home) which uses our Home component
  • every URLs which are not listed in our routes will make the Vue.js application redirects the user to the homepage

Our router is ready, but we still have to reference it in our Vue.js application entry point:

index.js:

import Vue from "vue";
import App from "./App";
import router from "./router";

new Vue({
  components: { App },
  template: "<App/>",
  router
}).$mount("#app");

And update the App component:

<template>
  <div class="container">
    <nav class="navbar navbar-expand-lg navbar-light bg-light">
      <router-link
        class="navbar-brand"
        to="/home"
      >
        App
      </router-link>
      <button
        class="navbar-toggler"
        type="button"
        data-toggle="collapse"
        data-target="#navbarNav"
        aria-controls="navbarNav"
        aria-expanded="false"
        aria-label="Toggle navigation"
      >
        <span class="navbar-toggler-icon" />
      </button>
      <div
        id="navbarNav"
        class="collapse navbar-collapse"
      >
        <ul class="navbar-nav">
          <router-link
            class="nav-item"
            tag="li"
            to="/home"
            active-class="active"
          >
            <a class="nav-link">Home</a>
          </router-link>
        </ul>
      </div>
    </nav>

    <router-view />
  </div>
</template>

<script>
  export default {
    name: "App",
  }
</script>
  • <router-link> is a Vue.js tag to create links to our pages. It accepts attributes like tag which is the HTML tag it will be replaced by, to for the link path and active-class which adds the given class if the current path matches with the to attribute
  • <router-view> is the tag where will be injected your pages

Let's rebuild our source code with yarn dev and go to app.localhost: you should automatically be redirected to app.localhost/home and see the homepage.

Unfortunately, we're not done: if you refresh the page, you will get a 404 Not Found error. ☹

This is because Symfony is still the main entry point of our application and it does not have a route /home.

But don't worry, there is a simple solution: just update the indexAction from the IndexController.php:

/**
 * @Route("/{vueRouting}", name="index")
 * @return Response
 */
public function indexAction(): Response
{
    return $this->render('base.html.twig', []);
}

As you can see, we've defined an optional route parameter vueRouting which contains for example home if you've requested the path /home.

Voilà! ☺

As our routing is done, we'll see in the next section how to create API endpoints with Symfony to serve data from our database.

A REST API with Symfony

As explained in the introduction, our application should allow user to post messages.

So let's start by creating a Post entity.

Instead of incremental ids, we're using uuid with the ramsey/uuid-doctrine library. Run composer require ramsey/uuid-doctrine to install it!

Post.php:

<?php

declare(strict_types=1);

namespace App\Entity;

use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Exception;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;

/**
 * @ORM\Entity
 * @ORM\Table(name="posts")
 * @ORM\HasLifecycleCallbacks
 */
class Post
{
    /**
     * @ORM\Id
     * @ORM\Column(type="uuid", unique=true)
     *
     * @var UuidInterface
     */
    private $id;

    /**
     * @ORM\Column(name="message", type="string")
     *
     * @var string
     */
    private $message;

    /**
     * @ORM\Column(name="created", type="datetime")
     *
     * @var DateTime
     */
    private $created;

    /**
     * @ORM\Column(name="updated", type="datetime", nullable=true)
     *
     * @var DateTime|null
     */
    private $updated;

    /**
     * @ORM\PrePersist
     *
     * @throws Exception;
     */
    public function onPrePersist(): void
    {
        $this->id = Uuid::uuid4();
        $this->created = new DateTime('NOW');
    }

    /**
     * @ORM\PreUpdate
     */
    public function onPreUpdate(): void
    {
        $this->updated = new DateTime('NOW');
    }

    public function getId(): UuidInterface
    {
        return $this->id;
    }

    public function getMessage(): string
    {
        return $this->message;
    }

    public function setMessage(string $message): void
    {
        $this->message = $message;
    }

    public function getCreated(): DateTime
    {
        return $this->created;
    }

    public function getUpdated(): ?DateTime
    {
        return $this->updated;
    }
}

The next step is to create the posts table in our database.

For this purpose, we install the DoctrineMigrations bundle:

$ composer require doctrine/doctrine-migrations-bundle "^2.0"

Good? Let's create a migration patch and execute it:

$ php bin/console doctrine:migrations:diff
$ php bin/console doctrine:migrations:migrate

Our database is now ready with its posts table! Still, we have to create our API endpoints which will be used by our Vue.js application.

First, we have to install the FOSRest and Symfony Serializer bundles:

$ composer require friendsofsymfony/rest-bundle

Then we create a controller which handles the logic of our API:

PostController.php:

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Post;
use Doctrine\ORM\EntityManagerInterface;
use FOS\RestBundle\Controller\Annotations as Rest;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\SerializerInterface;

/**
 * @Rest\Route("/api")
 */
final class PostController extends AbstractController
{
    /** @var EntityManagerInterface */
    private $em;

    /** @var SerializerInterface */
    private $serializer;

    public function __construct(EntityManagerInterface $em, SerializerInterface $serializer)
    {
        $this->em = $em;
        $this->serializer = $serializer;
    }

    /**
     * @throws BadRequestHttpException
     *
     * @Rest\Post("/posts", name="createPost")
     */
    public function createAction(Request $request): JsonResponse
    {
        $message = $request->request->get('message');
        if (empty($message)) {
            throw new BadRequestHttpException('message cannot be empty');
        }
        $post = new Post();
        $post->setMessage($message);
        $this->em->persist($post);
        $this->em->flush();
        $data = $this->serializer->serialize($post, JsonEncoder::FORMAT);

        return new JsonResponse($data, Response::HTTP_CREATED, [], true);
    }

    /**
     * @Rest\Get("/posts", name="findAllPosts")
     */
    public function findAllAction(): JsonResponse
    {
        $posts = $this->em->getRepository(Post::class)->findBy([], ['id' => 'DESC']);
        $data = $this->serializer->serialize($posts, JsonEncoder::FORMAT);

        return new JsonResponse($data, Response::HTTP_OK, [], true);
    }
}

As you may have notice, our routes begin with /api. We need to exclude those routes from the indexAction of the IndexController.php:

/**
 * @Route("/{vueRouting}", requirements={"vueRouting"="^(?!api|_(profiler|wdt)).*"}, name="index")
 * @return Response
 */
public function indexAction(): Response
{
    return $this->render('base.html.twig', []);
}

By adding the attribute requirements={"vueRouting"="^(?!api|_(profiler|wdt)).*"} to our route, it will not handle anymore routes beginning with /api. Please note that the Symfony debug bar is also excluded from this route.

Also, we're throwing a BadRequestHttpException if there is no message. As we communicate with our frontend using the JSON format, we need to intercept such exceptions to transform them to JsonResponse.

Let's begin by creating our exception listener:

<?php

declare(strict_types=1);

namespace App\Exception;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use function strpos;

final class HTTPExceptionListener
{
    public function onKernelException(ExceptionEvent $event): void
    {
        $exception = $event->getException();
        if (! ($exception instanceof HttpException) || strpos($event->getRequest()->getRequestUri(), '/api/') === false) {
            return;
        }

        $response = new JsonResponse(['error' => $exception->getMessage()]);
        $response->setStatusCode($exception->getStatusCode());
        $event->setResponse($response);
    }
}

Here we check if the exception is an instance of HTTPException and if we're calling an API endpoint.

Now we need to hook this listener; let's add it to the file config/service.yaml:

  App\Exception\HTTPExceptionListener:
        tags:
            - { name: kernel.event_listener, event: kernel.exception }

Our backend is now ready to serve data!

If you go to app.localhost/api/posts, you should see an empty JSON printed in the browser. ☺

In the next section, we'll see how to test our API endpoints with PHPUnit.

PHPUnit

Let's begin by updating our docker-compose.yml file with a dedicated database for our tests:

  mysql_tests:
    image: mysql:5.7
    environment:
      MYSQL_ROOT_PASSWORD: "admin"
      MYSQL_DATABASE: "tests"
      MYSQL_USER: "foo"
      MYSQL_PASSWORD: "bar"
    volumes:
      - ./services/mysql/utf8mb4.cnf:/etc/mysql/conf.d/utf8mb4.cnf:ro

We also need to reset the database every time we run our tests.

Best option is to use the DoctrineFixtures bundle:

$ composer require --dev doctrine/doctrine-fixtures-bundle
The DoctrineFixtures will also help us to put some default data later in this tutorial.

Once done, create the file bootstrap.php at the root of your tests folder:

<?php

declare(strict_types=1);

use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;

require dirname(__DIR__) . '/vendor/autoload.php';

$_SERVER += $_ENV;
$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null ?: 'dev';
$_SERVER['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? $_SERVER['APP_ENV'] !== 'prod';
$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = (int) $_SERVER['APP_DEBUG'] || filter_var($_SERVER['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN) ? '1' : '0';

$process = new Process(['php', 'bin/console', 'doctrine:migrations:migrate', '--no-interaction']);
$process->run();
if (! $process->isSuccessful()) {
    throw new ProcessFailedException($process);
}

$process = new Process(['php', 'bin/console', 'doctrine:fixtures:load', '--no-interaction']);
$process->run();
if (! $process->isSuccessful()) {
    throw new ProcessFailedException($process);
}

The content is quite close from the content of the file config/bootstrap.php.

Only difference is that we also run the migrations and the command php bin/console doctrine:fixtures:load which purges the database.

We may now update our phpunit.xml.dist configuration file with:

<?xml version="1.0" encoding="UTF-8"?>

<!-- https://phpunit.de/manual/current/en/appendixes.configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/6.5/phpunit.xsd"
         backupGlobals="false"
         colors="true"
         bootstrap="tests/bootstrap.php"
>
    <php>
        <ini name="error_reporting" value="-1" />
        <env name="APP_ENV" value="test" force="true" />
        <env name="SHELL_VERBOSITY" value="-1" />
        <env name="SYMFONY_PHPUNIT_REMOVE" value="" />
        <env name="SYMFONY_PHPUNIT_VERSION" value="6.5" />
        <env name="KERNEL_CLASS" value="App\Kernel" />
        <env name="DATABASE_URL" value="mysql://foo:bar@mysql_tests/tests" force="true" />
    </php>

    <testsuites>
        <testsuite name="Project Test Suite">
            <directory>tests</directory>
        </testsuite>
    </testsuites>

    <filter>
        <whitelist>
            <directory>src</directory>
        </whitelist>
    </filter>

    <listeners> 
        <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
    </listeners>
</phpunit>

Here we have:

  • updated the bootstrap file by replacing config/bootstrap.php with tests/bootstrap.php
  • replaced the tags <server /> with <env /> in the php tag
  • added the DATABASE_URL environment variable pointing to the database created before
  • added KERNEL_CLASS environment variable which is required

You may now stop your containers and re-up them:

$ docker-compose down
$ docker-compose up -d
$ docker-compose exec app bash

For integrating Symfony with PHPUnit, we have to install the PHPUnit Bridge component:

$ composer require --dev symfony/phpunit-bridge
$ php bin/phpunit

Good? Now let's create a Controller folder inside the tests folder. This is where we will store the tests related to our controllers.

For helping us writting test, we'll create a abstract class with useful methods:

AbstractControllerWebTestCase.php:

<?php

declare(strict_types=1);

namespace App\Tests\Controller;

use Safe\Exceptions\JsonException;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
use function Safe\json_decode;
use function Safe\json_encode;

abstract class AbstractControllerWebTestCase extends WebTestCase
{
    /** @var KernelBrowser */
    protected $client;

    protected function setUp(): void
    {
        self::bootKernel();
        $this->client = static::createClient();
    }

    /**
     * @param mixed[] $data
     *
     * @throws JsonException
     */
    protected function JSONRequest(string $method, string $uri, array $data = []): void
    {
        $this->client->request($method, $uri, [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($data));
    }

    /**
     * @return mixed
     *
     * @throws JsonException
     */
    protected function assertJSONResponse(Response $response, int $expectedStatusCode)
    {
        $this->assertEquals($expectedStatusCode, $response->getStatusCode());
        $this->assertTrue($response->headers->contains('Content-Type', 'application/json'));
        $this->assertJson($response->getContent());

        return json_decode($response->getContent(), true);
    }
}

Then we create the test class PostControllerTest where we'll test our PostController:

<?php

declare(strict_types=1);

namespace App\Tests\Controller;

use Safe\Exceptions\JsonException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use function count;

final class PostControllerTest extends AbstractControllerWebTestCase
{
    /**
     * @throws JsonException
     */
    public function testCreatePost(): void
    {
        // test that sending no message will result to a bad request HTTP code.
        $this->JSONRequest(Request::METHOD_POST, '/api/posts');
        $this->assertJSONResponse($this->client->getResponse(), Response::HTTP_BAD_REQUEST);
        // test that sending a correct message will result to a created HTTP code.
        $this->JSONRequest(Request::METHOD_POST, '/api/posts', ['message' => 'Hello world!']);
        $this->assertJSONResponse($this->client->getResponse(), Response::HTTP_CREATED);
    }

    /**
     * @throws JsonException
     */
    public function testFindAllPosts(): void
    {
        $this->client->request(Request::METHOD_GET, '/api/posts');
        $response = $this->client->getResponse();
        $content = $this->assertJSONResponse($response, Response::HTTP_OK);
        $this->assertEquals(1, count($content));
    }
}

You may now run your tests suite with php bin/phpunit!

In the next section, we'll see how to consume our API endpoints with Vue.js and how to store the retrieved data.

Consuming an API with Vue.js

For consuming our API endpoints, we'll use the axios library for our Ajax calls:

$ yarn add axios

Next we create our first consumer by creating the post.js file:

├── assets/
│   ├── vue/
│   │   ├── api/
│   │   │   ├── post.js

post.js:

import axios from "axios";

export default {
  create(message) {
    return axios.post("/api/posts", {
      message: message
    });
  },
  findAll() {
    return axios.get("/api/posts");
  }
};

Now you may wonder where will you call those methods and store the corresponding data.

We'll actually use a store from the Vuex library.

$ yarn add vuex

Vuex is a state management pattern + library for Vue.js applications. It serves as a centralized store for all the components in an application, with rules ensuring that the state can only be mutated in a predictable fashion.

-- Vuex documentation

In others words, we'll have a main store which centralizes the data from our application.

This store will be accessible from all our components: if one component updates a value from the store, all components using this store will be updated too!

Also, a good practice is to split our main store into specialized modules. A module may be viewed as a department from a real store. Each module should be specialized and only manage data from its scope.

But enough theory; let's create a module for our posts:

├── assets/
│   ├── vue/
│   │   ├── store/
│   │   │   ├── post.js

post.js:

import PostAPI from "../api/post";

const CREATING_POST = "CREATING_POST",
  CREATING_POST_SUCCESS = "CREATING_POST_SUCCESS",
  CREATING_POST_ERROR = "CREATING_POST_ERROR",
  FETCHING_POSTS = "FETCHING_POSTS",
  FETCHING_POSTS_SUCCESS = "FETCHING_POSTS_SUCCESS",
  FETCHING_POSTS_ERROR = "FETCHING_POSTS_ERROR";

export default {
  namespaced: true,
  state: {
    isLoading: false,
    error: null,
    posts: []
  },
  getters: {
    isLoading(state) {
      return state.isLoading;
    },
    hasError(state) {
      return state.error !== null;
    },
    error(state) {
      return state.error;
    },
    hasPosts(state) {
      return state.posts.length > 0;
    },
    posts(state) {
      return state.posts;
    }
  },
  mutations: {
    [CREATING_POST](state) {
      state.isLoading = true;
      state.error = null;
    },
    [CREATING_POST_SUCCESS](state, post) {
      state.isLoading = false;
      state.error = null;
      state.posts.unshift(post);
    },
    [CREATING_POST_ERROR](state, error) {
      state.isLoading = false;
      state.error = error;
      state.posts = [];
    },
    [FETCHING_POSTS](state) {
      state.isLoading = true;
      state.error = null;
      state.posts = [];
    },
    [FETCHING_POSTS_SUCCESS](state, posts) {
      state.isLoading = false;
      state.error = null;
      state.posts = posts;
    },
    [FETCHING_POSTS_ERROR](state, error) {
      state.isLoading = false;
      state.error = error;
      state.posts = [];
    }
  },
  actions: {
    async create({ commit }, message) {
      commit(CREATING_POST);
      try {
        let response = await PostAPI.create(message);
        commit(CREATING_POST_SUCCESS, response.data);
        return response.data;
      } catch (error) {
        commit(CREATING_POST_ERROR, error);
        return null;
      }
    },
    async findAll({ commit }) {
      commit(FETCHING_POSTS);
      try {
        let response = await PostAPI.findAll();
        commit(FETCHING_POSTS_SUCCESS, response.data);
        return response.data;
      } catch (error) {
        commit(FETCHING_POSTS_ERROR, error);
        return null;
      }
    }
  }
};

Yes I know, a lot is going on here!

But it's actually quite simple:

  • the attribute namespaced: true allows to reference directly this module when calling our main store (we'll create it just after)
  • the state contains our module data
  • getters are simply methods which allow us to retrieve the data from the state
  • mutations are named states of our data
  • actions are methods which will mutate our module state

Good? We may now reference this module by creating the main store:

├── assets/
│   ├── vue/
│   ├── ├── store/
│   ├── ├── ├── index.js
│   ├── ├── ├── post.js

index.js:

import Vue from "vue";
import Vuex from "vuex";
import PostModule from "./post";

Vue.use(Vuex);

export default new Vuex.Store({
  modules: {
    post: PostModule
  }
});

And reference this main store in our Vue.js application entry point:

index.js:

import Vue from "vue";
import App from "./App";
import router from "./router";
import store from "./store";

new Vue({
  components: { App },
  template: "<App/>",
  router,
  store
}).$mount("#app");

Alright, our main store and its module are now available in our components.

So let's create a new page which will use them:

├── assets/
│   ├── vue/
│   │   ├── components/
│   │   │   ├── Post.vue
│   │   ├── views/
│   │   │   ├── Posts.vue

Posts.vue:

<template>
  <div>
    <div class="row col">
      <h1>Posts</h1>
    </div>

    <div class="row col">
      <form>
        <div class="form-row">
          <div class="col-8">
            <input
              v-model="message"
              type="text"
              class="form-control"
            >
          </div>
          <div class="col-4">
            <button
              :disabled="message.length === 0 || isLoading"
              type="button"
              class="btn btn-primary"
              @click="createPost()"
            >
              Create
            </button>
          </div>
        </div>
      </form>
    </div>

    <div
      v-if="isLoading"
      class="row col"
    >
      <p>Loading...</p>
    </div>

    <div
      v-else-if="hasError"
      class="row col"
    >
      <div
        class="alert alert-danger"
        role="alert"
      >
        {{ error }}
      </div>
    </div>

    <div
      v-else-if="!hasPosts"
      class="row col"
    >
      No posts!
    </div>

    <div
      v-for="post in posts"
      v-else
      :key="post.id"
      class="row col"
    >
      <post :message="post.message" />
    </div>
  </div>
</template>

<script>
import Post from "../components/Post";

export default {
  name: "Posts",
  components: {
    Post
  },
  data() {
    return {
      message: ""
    };
  },
  computed: {
    isLoading() {
      return this.$store.getters["post/isLoading"];
    },
    hasError() {
      return this.$store.getters["post/hasError"];
    },
    error() {
      return this.$store.getters["post/error"];
    },
    hasPosts() {
      return this.$store.getters["post/hasPosts"];
    },
    posts() {
      return this.$store.getters["post/posts"];
    }
  },
  created() {
    this.$store.dispatch("post/findAll");
  },
  methods: {
    async createPost() {
      const result = await this.$store.dispatch("post/create", this.$data.message);
      if (result !== null) {
        this.$data.message = "";
      }
    }
  }
};
</script>

Post.vue:

<template>
  <div class="card w-100 mt-2">
    <div class="card-body">
      {{ message }}
    </div>
  </div>
</template>

<script>
export default {
  name: "Post",
  props: {
    message: {
      type: String,
      required: true
    }
  }
};
</script>
  • we may access to our store's modules getters with this.$store.getters['moduleName/getterName']
  • same for our store's modules actions with this.$store.dispatch('moduleName/actionName', args...)

It's now time for the final touch: create a new route /posts for the Posts component:

index.js:

import Vue from "vue";
import VueRouter from "vue-router";
import Home from "../views/Home";
import Posts from "../views/Posts";

Vue.use(VueRouter);

export default new VueRouter({
  mode: "history",
  routes: [
    { path: "/home", component: Home },
    { path: "/posts", component: Posts },
    { path: "*", redirect: "/home" }
  ]
});

App.vue:

<template>
  <div class="container">
    <nav class="navbar navbar-expand-lg navbar-light bg-light">
      <router-link
        class="navbar-brand"
        to="/home"
      >
        App
      </router-link>
      <button
        class="navbar-toggler"
        type="button"
        data-toggle="collapse"
        data-target="#navbarNav"
        aria-controls="navbarNav"
        aria-expanded="false"
        aria-label="Toggle navigation"
      >
        <span class="navbar-toggler-icon" />
      </button>
      <div
        id="navbarNav"
        class="collapse navbar-collapse"
      >
        <ul class="navbar-nav">
          <router-link
            class="nav-item"
            tag="li"
            to="/home"
            active-class="active"
          >
            <a class="nav-link">Home</a>
          </router-link>
          <router-link
            class="nav-item"
            tag="li"
            to="/posts"
            active-class="active"
          >
            <a class="nav-link">Posts</a>
          </router-link>
        </ul>
      </div>
    </nav>

    <router-view />
  </div>
</template>

Rebuild your Vue.js source code (yarn dev) and go to app.localhost/posts to see your developments in action! ☺

In the next section comes the most complicated part: I'll explain how to restrict the access to the posts to authenticated user.

Security

Symfony

We start by creating a User entity:

User.php:

<?php

declare(strict_types=1);

namespace App\Entity;

use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Exception;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity
 * @ORM\Table(name="users")
 * @ORM\HasLifecycleCallbacks
 */
class User implements UserInterface
{
    /**
     * @ORM\Id
     * @ORM\Column(type="uuid", unique=true)
     *
     * @var UuidInterface
     */
    private $id;

    /**
     * @ORM\Column(name="login", type="string", unique=true)
     *
     * @var string
     * @Assert\NotBlank()
     */
    private $login;

    /**
     * @var string|null
     * @Assert\NotBlank()
     * @Assert\Length(max=4096)
     */
    private $plainPassword;

    /**
     * @ORM\Column(name="password", type="string")
     *
     * @var string|null
     */
    private $password;

    /**
     * @ORM\Column(name="roles", type="simple_array")
     *
     * @var string[]
     */
    private $roles;

    /**
     * @ORM\Column(name="created", type="datetime")
     *
     * @var DateTime
     */
    private $created;

    /**
     * @ORM\Column(name="updated", type="datetime", nullable=true)
     *
     * @var DateTime
     */
    private $updated;

    public function __construct()
    {
        $this->roles = [];
    }

    /**
     * @ORM\PrePersist
     *
     * @throws Exception
     */
    public function onPrePersist(): void
    {
        $this->id = Uuid::uuid4();
        $this->created = new DateTime('NOW');
    }

    /**
     * @ORM\PreUpdate
     */
    public function onPreUpdate(): void
    {
        $this->updated = new DateTime('NOW');
    }

    public function getId(): UuidInterface
    {
        return $this->id;
    }

    public function getLogin(): string
    {
        return $this->login;
    }

    public function setLogin(string $login): void
    {
        $this->login = $login;
    }

    public function getUsername(): string
    {
        return $this->login;
    }

    public function getPlainPassword(): ?string
    {
        return $this->plainPassword;
    }

    public function setPlainPassword(string $password): void
    {
        $this->plainPassword = $password;

        // forces the object to look "dirty" to Doctrine. Avoids
        // Doctrine *not* saving this entity, if only plainPassword changes
        $this->password = null;
    }

    public function getPassword(): ?string
    {
        return $this->password;
    }

    public function setPassword(string $password): void
    {
        $this->password = $password;
    }

    /**
     * @return null
     */
    public function getSalt()
    {
        // The bcrypt algorithm doesn't require a separate salt.
        return null;
    }

    /**
     * @return string[]
     */
    public function getRoles(): array
    {
        return $this->roles;
    }

    /**
     * @param string[] $roles
     */
    public function setRoles(array $roles): void
    {
        $this->roles = $roles;
    }

    public function eraseCredentials(): void
    {
        $this->plainPassword = null;
    }

    public function getCreated(): DateTime
    {
        return $this->created;
    }

    public function getUpdated(): ?DateTime
    {
        return $this->updated;
    }
}

Like the Post entity, we need to update our database with:

$ php bin/console doctrine:migrations:diff
$ php bin/console doctrine:migrations:migrate

Alright, now let's add an event listener to encode our users' password on persist:

HashPasswordListener.php:

<?php

declare(strict_types=1);

namespace App\Security;

use App\Entity\User;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use function get_class;

final class HashPasswordListener implements EventSubscriber
{
    /** @var UserPasswordEncoderInterface */
    private $passwordEncoder;

    public function __construct(UserPasswordEncoderInterface $passwordEncoder)
    {
        $this->passwordEncoder = $passwordEncoder;
    }

    public function prePersist(LifecycleEventArgs $args): void
    {
        $entity = $args->getEntity();
        if (! $entity instanceof User) {
            return;
        }

        $this->encodePassword($entity);
    }

    public function preUpdate(LifecycleEventArgs $args): void
    {
        $entity = $args->getEntity();
        if (! $entity instanceof User) {
            return;
        }

        $this->encodePassword($entity);
        // necessary to force the update to see the change
        $em = $args->getEntityManager();
        $meta = $em->getClassMetadata(get_class($entity));
        $em->getUnitOfWork()->recomputeSingleEntityChangeSet($meta, $entity);
    }

    /**
     * {@inheritdoc}
     */
    public function getSubscribedEvents()
    {
        return ['prePersist', 'preUpdate'];
    }

    private function encodePassword(User $entity): void
    {
        $plainPassword = $entity->getPlainPassword();
        if ($plainPassword === null) {
            return;
        }

        $encoded = $this->passwordEncoder->encodePassword(
            $entity,
            $plainPassword
        );

        $entity->setPassword($encoded);
    }
}

And register it in the file located at config/services.yaml:

app.security.hash.password.listener:
        class: App\Security\HashPasswordListener
        tags:
        - { name: doctrine.event_subscriber }

Next step is to configure Symfony by telling it to use our User entity for JSON authentication.

Replace the content of the file located at config/packages/security.yaml with:

security:
    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    encoders:
        App\Entity\User:
            algorithm: auto
    providers:
        in_memory: { memory: ~ }
        pdo:
            entity:
                class: App\Entity\User
                property: login
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            anonymous: true

            provider: pdo

            json_login:
                check_path: /api/security/login

            logout:
                path: /api/security/logout

            # activate different ways to authenticate

            # http_basic: true
            # https://symfony.com/doc/current/security.html#a-configuring-how-your-users-will-authenticate

            # form_login: true
            # https://symfony.com/doc/current/security/form_login_setup.html

    # 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: ^/profile, roles: ROLE_USER }
  • we added an encoder for the password of our users (auto)
  • we told Symfony that our resource for authentication is the User entity
  • we provided a json_login method with the login route /api/security/login
  • we provided a logout route /api/security/logout

Now we need to tell Symfony how to store the sessions which contain our authenticated users.

The best option currently is to store the sessions in a remote location - MySQL for instance.

You could also store the sessions using files, but your application will not be stateless anymore. This may cause you some troubles if you want to scale your application in production.

Let's begin by replacing the content of the file located at config/packages/frameworks.yaml:

framework:
    secret: '%env(APP_SECRET)%'
    #default_locale: en
    #csrf_protection: true
    #http_method_override: true

    # 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: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler
        cookie_secure: auto
        cookie_samesite: lax

    #esi: true
    #fragments: true
    php_errors:
        log: true

Then update the file located at config/services.yaml with:

Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
    arguments:
        - !service { class: PDO, factory: 'database_connection:getWrappedConnection' }
        # If you get transaction issues (e.g. after login) uncomment the line below
        - { lock_mode: 1 }

We also need to create the sessions table:

$ php bin/console doctrine:migrations:generate

Once your new migration is created, update it with the following content:

public function up(Schema $schema) : void
{
    $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');

    $this->addSql('CREATE TABLE sessions (sess_id VARCHAR(128) NOT NULL PRIMARY KEY, sess_data BLOB NOT NULL, sess_time INTEGER UNSIGNED NOT NULL, sess_lifetime MEDIUMINT NOT NULL) COLLATE utf8_bin, ENGINE = InnoDB');
}

public function down(Schema $schema) : void
{
    $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');

    $this->addSql('DROP TABLE sessions');
}

Apply the migration:

$ php bin/console doctrine:migrations:migrate

Let's not forget update the file located at config/packages/doctrine.yaml with:

doctrine:
    dbal:
      # ...
      schema_filter: ~^(?!sessions)~

This will prevent Doctrine to add the DROP TABLE sessions when running php bin/console doctrine:migrations:diff (as there is no Session entity).

Good? We may now continue with our security API endpoints:

SecurityController.php:

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\User;
use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\SerializerInterface;

/**
 * @Route("/api")
 */
final class SecurityController extends AbstractController
{
    /** @var SerializerInterface */
    private $serializer;

    public function __construct(SerializerInterface $serializer)
    {
        $this->serializer = $serializer;
    }

    /**
     * @Route("/security/login", name="login")
     */
    public function loginAction(): JsonResponse
    {
        /** @var User $user */
        $user = $this->getUser();
        $userClone = clone $user;
        $userClone->setPassword('');
        $data = $this->serializer->serialize($userClone, JsonEncoder::FORMAT);

        return new JsonResponse($data, Response::HTTP_OK, [], true);
    }

    /**
     * @throws RuntimeException
     *
     * @Route("/security/logout", name="logout")
     */
    public function logoutAction(): void
    {
        throw new RuntimeException('This should not be reached!');
    }
}
We are cloning our $user to be able to set a blank password. Otherwise updating directly the $user will result to an invalid session.

Let's not forget to add a @IsGranted("IS_AUTHENTICATED_FULLY") to our PostController class to prevent non-authenticated users to manipulate our posts:

PostController.php:

use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;

/**
 * @Rest\Route("/api")
 * @IsGranted("IS_AUTHENTICATED_FULLY")
 */
final class PostController extends AbstractController

Also, we restrict the post creation to users who have the role ROLE_FOO:

PostController.php:

/**
 * @Rest\Post("/api/posts", name="createPost")
 * @IsGranted("ROLE_FOO")
 */
public function createAction(Request $request): JsonResponse

Last but not least, we want to create a default user for our tests:

UserFixtures.php:

<?php

declare(strict_types=1);

namespace App\DataFixtures;

use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;

final class UserFixtures extends Fixture
{
    public const DEFAULT_USER_LOGIN = 'login';

    public const DEFAULT_USER_PASSWORD = 'bar';

    public function load(ObjectManager $manager): void
    {
        $userEntity = new User();
        $userEntity->setLogin(self::DEFAULT_USER_LOGIN);
        $userEntity->setPlainPassword(self::DEFAULT_USER_PASSWORD);
        $userEntity->setRoles(['ROLE_FOO']);
        $manager->persist($userEntity);
        $manager->flush();
    }
}

And run php bin/console doctrine:fixtures:load to load it into the database! ☺

PHPUnit

Right now, our tests suite for the PostController does not work anymore: indeed, we need to be authenticated in order to use those API endpoints.

Let's begin to add a new user in our UserFixtures as we want to test among other things if a user without the role ROLE_FOO is indeed not able to create a post:

<?php

declare(strict_types=1);

namespace App\DataFixtures;

use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;

final class UserFixtures extends Fixture
{
    public const DEFAULT_USER_LOGIN = 'foo';

    public const DEFAULT_USER_PASSWORD = 'bar';

    public const USER_LOGIN_ROLE_BAR = 'bar';

    public const USER_PASSWORD_ROLE_BAR = 'foo';

    public function load(ObjectManager $manager): void
    {
        $this->createUser($manager, self::DEFAULT_USER_LOGIN, self::DEFAULT_USER_PASSWORD, ['ROLE_FOO']);
        $this->createUser($manager, self::USER_LOGIN_ROLE_BAR, self::USER_PASSWORD_ROLE_BAR, ['ROLE_BAR']);
    }

    /**
     * @param string[] $roles
     */
    private function createUser(ObjectManager $manager, string $login, string $password, array $roles): void
    {
        $userEntity = new User();
        $userEntity->setLogin($login);
        $userEntity->setPlainPassword($password);
        $userEntity->setRoles($roles);
        $manager->persist($userEntity);
        $manager->flush();
    }
}

We may now update our helper class AbstractControllerWebTestCase by adding a new method for simulating a login:

<?php

declare(strict_types=1);

namespace App\Tests\Controller;

use App\DataFixtures\UserFixtures;
use Safe\Exceptions\JsonException;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use function Safe\json_decode;
use function Safe\json_encode;

abstract class AbstractControllerWebTestCase extends WebTestCase
{
    /** @var KernelBrowser */
    protected $client;

    protected function setUp(): void
    {
        self::bootKernel();
        $this->client = static::createClient();
    }

   /**
    * @param mixed[] $data
    *
    * @throws JsonException
    */
   protected function JSONRequest(string $method, string $uri, array $data = []): void
   {
       $this->client->request($method, $uri, [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($data));
   }

    /**
     * @return mixed
     *
     * @throws JsonException
     */
    protected function assertJSONResponse(Response $response, int $expectedStatusCode)
    {
        $this->assertEquals($expectedStatusCode, $response->getStatusCode());
        $this->assertTrue($response->headers->contains('Content-Type', 'application/json'));
        $this->assertJson($response->getContent());

        return json_decode($response->getContent(), true);
    }

    /**
     * @throws JsonException
     */
    protected function login(string $username = UserFixtures::DEFAULT_USER_LOGIN, string $password = UserFixtures::DEFAULT_USER_PASSWORD): void
    {
        $this->client->request(Request::METHOD_POST, '/api/security/login', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['username' => $username, 'password' => $password]));
        $this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
    }
}

Last but not least, let's update our PostControllerTest tests suite:

<?php

declare(strict_types=1);

namespace App\Tests\Controller;

use App\DataFixtures\UserFixtures;
use Safe\Exceptions\JsonException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use function count;

final class PostControllerTest extends AbstractControllerWebTestCase
{
    /**
     * @throws JsonException
     */
    public function testCreatePost(): void
    {
        // test that sending a request without being authenticated will result to a unauthorized HTTP code.
        $this->JSONRequest(Request::METHOD_POST, '/api/posts');
        $this->assertJSONResponse($this->client->getResponse(), Response::HTTP_UNAUTHORIZED);
        // test that sending a request while not having the role "ROLE_FOO" will result to a forbidden HTTP code.
        $this->login(UserFixtures::USER_LOGIN_ROLE_BAR, UserFixtures::USER_PASSWORD_ROLE_BAR);
        $this->JSONRequest(Request::METHOD_POST, '/api/posts');
        $this->assertJSONResponse($this->client->getResponse(), Response::HTTP_FORBIDDEN);
        // test that sending a request while begin authenticated will result to a created HTTP code.
        $this->login();
        $this->JSONRequest(Request::METHOD_POST, '/api/posts', ['message' => 'Hello world!']);
        $this->assertJSONResponse($this->client->getResponse(), Response::HTTP_CREATED);
        // test that sending no message will result to a bad request HTTP code.
        $this->JSONRequest(Request::METHOD_POST, '/api/posts');
        $this->assertJSONResponse($this->client->getResponse(), Response::HTTP_BAD_REQUEST);
    }

    /**
     * @throws JsonException
     */
    public function testFindAllPosts(): void
    {
        // test that sending a request without being authenticated will result to a unauthorized HTTP code.
        $this->client->request(Request::METHOD_GET, '/api/posts');
        $this->assertJSONResponse($this->client->getResponse(), Response::HTTP_UNAUTHORIZED);
        // test that sending a request while begin authenticated will result to a OK HTTP code.
        $this->login();
        $this->client->request(Request::METHOD_GET, '/api/posts');
        $response = $this->client->getResponse();
        $content = $this->assertJSONResponse($response, Response::HTTP_OK);
        $this->assertEquals(1, count($content));
    }
}

You may now run your tests suite with php bin/phpunit!

Pheww... Our backend is now ready to handle JSON authentication! ☺

But we're not done yet: we need to update our Vue.js application to prevent the user from accessing to the posts if he's not authenticated.

I'll show you how to do that in the next section!

Vue.js

Let's list the cases we have to handle:

  1. if the user tries to access the page /posts without being authenticated, we should redirect him to the login page
  2. if the user is authenticated and tries to access the /login page, we should redirect him to the home page
  3. if an Ajax call returns a 401 HTTP code, we should redirect the user to the login page
  4. if the user refreshes the page /posts and he's already authenticated, we should not redirect him to the login page

We begin by creating our security API consumer:

├── assets/
│   ├── vue/
│   │   ├── api/
│   │   │   ├── security.js

security.js:

import axios from "axios";

export default {
  login(login, password) {
    return axios.post("/api/security/login", {
      username: login,
      password: password
    });
  }
}

And the module of our main store which will... store the data related to security:

├── assets/
│   ├── vue/
│   │   ├── store/
│   │   │   ├── security.js

security.js:

import SecurityAPI from "../api/security";

const AUTHENTICATING = "AUTHENTICATING",
  AUTHENTICATING_SUCCESS = "AUTHENTICATING_SUCCESS",
  AUTHENTICATING_ERROR = "AUTHENTICATING_ERROR";

export default {
  namespaced: true,
  state: {
    isLoading: false,
    error: null,
    isAuthenticated: false,
    user: null
  },
  getters: {
    isLoading(state) {
      return state.isLoading;
    },
    hasError(state) {
      return state.error !== null;
    },
    error(state) {
      return state.error;
    },
    isAuthenticated(state) {
      return state.isAuthenticated;
    },
    hasRole(state) {
      return role => {
        return state.user.roles.indexOf(role) !== -1;
      }
    }
  },
  mutations: {
    [AUTHENTICATING](state) {
      state.isLoading = true;
      state.error = null;
      state.isAuthenticated = false;
      state.user = null;
    },
    [AUTHENTICATING_SUCCESS](state, user) {
      state.isLoading = false;
      state.error = null;
      state.isAuthenticated = true;
      state.user = user;
    },
    [AUTHENTICATING_ERROR](state, error) {
      state.isLoading = false;
      state.error = error;
      state.isAuthenticated = false;
      state.user = null;
    }
  },
  actions: {
    async login({commit}, payload) {
      commit(AUTHENTICATING);
      try {
        let response = await SecurityAPI.login(payload.login, payload.password);
        commit(AUTHENTICATING_SUCCESS, response.data);
        return response.data;
      } catch (error) {
        commit(AUTHENTICATING_ERROR, error);
        return null;
      }
    }
  }
}

Add this module to our main store:

index.js:

import Vue from "vue";
import Vuex from "vuex";
import SecurityModule from "./security";
import PostModule from "./post";

Vue.use(Vuex);

export default new Vuex.Store({
  modules: {
    security: SecurityModule,
    post: PostModule
  }
});

We may now use this module in a new Login component:

├── assets/
│   ├── vue/
│   │   ├── views/
│   │   │   ├── Login.vue

Login.vue:

<template>
  <div>
    <div class="row col">
      <h1>Login</h1>
    </div>

    <div class="row col">
      <form>
        <div class="form-row">
          <div class="col-4">
            <input
              v-model="login"
              type="text"
              class="form-control"
            >
          </div>
          <div class="col-4">
            <input
              v-model="password"
              type="password"
              class="form-control"
            >
          </div>
          <div class="col-4">
            <button
              :disabled="login.length === 0 || password.length === 0 || isLoading"
              type="button"
              class="btn btn-primary"
              @click="performLogin()"
            >
              Login
            </button>
          </div>
        </div>
      </form>
    </div>

    <div
      v-if="isLoading"
      class="row col"
    >
      <p>Loading...</p>
    </div>

    <div
      v-else-if="hasError"
      class="row col"
    >
      <div
        class="alert alert-danger"
        role="alert"
      >
        {{ error }}
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: "Login",
  data() {
    return {
      login: "",
      password: ""
    };
  },
  computed: {
    isLoading() {
      return this.$store.getters["security/isLoading"];
    },
    hasError() {
      return this.$store.getters["security/hasError"];
    },
    error() {
      return this.$store.getters["security/error"];
    }
  },
  created() {
    let redirect = this.$route.query.redirect;

    if (this.$store.getters["security/isAuthenticated"]) {
      if (typeof redirect !== "undefined") {
        this.$router.push({path: redirect});
      } else {
        this.$router.push({path: "/home"});
      }
    }
  },
  methods: {
    async performLogin() {
      let payload = {login: this.$data.login, password: this.$data.password},
        redirect = this.$route.query.redirect;

      await this.$store.dispatch("security/login", payload);
      if (!this.$store.getters["security/hasError"]) {
        if (typeof redirect !== "undefined") {
          this.$router.push({path: redirect});
        } else {
          this.$router.push({path: "/home"});
        }
      }
    }
  }
}
</script>

Let's not forget to update our router:

index.js:

import Vue from "vue";
import VueRouter from "vue-router";
import store from "../store";
import Home from "../views/Home";
import Login from "../views/Login";
import Posts from "../views/Posts";

Vue.use(VueRouter);

let router = new VueRouter({
  mode: "history",
  routes: [
    { path: "/home", component: Home },
    { path: "/login", component: Login },
    { path: "/posts", component: Posts, meta: { requiresAuth: true } },
    { path: "*", redirect: "/home" }
  ],
});

router.beforeEach((to, from, next) => {
  if (to.matched.some(record => record.meta.requiresAuth)) {
    // this route requires auth, check if logged in
    // if not, redirect to login page.
    if (store.getters["security/isAuthenticated"]) {
      next();
    } else {
      next({
        path: "/login",
        query: { redirect: to.fullPath }
      });
    }
  } else {
    next(); // make sure to always call next()!
  }
});

export default router;

So what's going on?

  • we added a new meta (requiresAuth: true) to our /posts path
  • before each route change, we check if this meta is available for the wanted route (which means the user has to be authenticated to access it)
  • if so, we call the getter isAuthenticated from our security module
  • if not authenticated, we redirect the user to the login page (with the optional redirect query parameter) On authentication success in our Login component, we check if this query parameter is available to redirect the user to its wanted route, otherwise we redirect him to the homepage

Simpler, the logout:

App.vue:

<template>
  <div class="container">
    <nav class="navbar navbar-expand-lg navbar-light bg-light">
      <router-link
        class="navbar-brand"
        to="/home"
      >
        App
      </router-link>
      <button
        class="navbar-toggler"
        type="button"
        data-toggle="collapse"
        data-target="#navbarNav"
        aria-controls="navbarNav"
        aria-expanded="false"
        aria-label="Toggle navigation"
      >
        <span class="navbar-toggler-icon" />
      </button>
      <div
        id="navbarNav"
        class="collapse navbar-collapse"
      >
        <ul class="navbar-nav">
          <router-link
            class="nav-item"
            tag="li"
            to="/home"
            active-class="active"
          >
            <a class="nav-link">Home</a>
          </router-link>
          <router-link
            class="nav-item"
            tag="li"
            to="/posts"
            active-class="active"
          >
            <a class="nav-link">Posts</a>
          </router-link>
          <li
            v-if="isAuthenticated"
            class="nav-item"
          >
            <a
              class="nav-link"
              href="/api/security/logout"
            >Logout</a>
          </li>
        </ul>
      </div>
    </nav>

    <router-view />
  </div>
</template>

<script>
export default {
  name: 'App',
  computed: {
    isAuthenticated() {
      return this.$store.getters['security/isAuthenticated']
    },
  },
}
</script>
  • we added a link to /api/security/logout: Symfony will automatically redirect the user to the root path
  • this link is conditionally displayed thanks to the getter isAuthenticated from our security module

Now let's handle the third case: if an Ajax call returns a 401 HTTP code, we should redirect the user to the login page.

We have to update our App.vue:

<script>
import axios from "axios";

export default {
  name: "App",
  computed: {
    isAuthenticated() {
      return this.$store.getters["security/isAuthenticated"]
    },
  },
  created() {
    axios.interceptors.response.use(undefined, (err) => {
      return new Promise(() => {
        if (err.response.status === 401) {
          this.$router.push({path: "/login"})
        }
        throw err;
      });
    });
  },
}
</script>
  • we added the created attribute where we're telling axios that for any Ajax calls which returns a 401 HTTP code, we redirect the user to the login page

Last case: if the user refreshes the page /posts and he's already authenticated, we should not redirect him to the login page.

We begin by updating our IndexController.php:

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\User;
use Safe\Exceptions\JsonException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\SerializerInterface;
use function Safe\json_encode;

final class IndexController extends AbstractController
{
    /** @var SerializerInterface */
    private $serializer;

    public function __construct(SerializerInterface $serializer)
    {
        $this->serializer = $serializer;
    }

    /**
     * @throws JsonException
     *
     * @Route("/{vueRouting}", requirements={"vueRouting"="^(?!api|_(profiler|wdt)).*"}, name="index")
     */
    public function indexAction(): Response
    {
        /** @var User|null $user */
        $user = $this->getUser();
        $data = null;
        if (! empty($user)) {
            $userClone = clone $user;
            $userClone->setPassword('');
            $data = $this->serializer->serialize($userClone, JsonEncoder::FORMAT);
        }

        return $this->render('base.html.twig', [
            'isAuthenticated' => json_encode(! empty($user)),
            'user' => $data ?? json_encode($data),
        ]);
    }
}

Then our base.html.twig:

 <div id="app" data-is-authenticated="{{ isAuthenticated }}" data-user="{{ user }}"></div>

Then our App.vue:

<script>
import axios from "axios";

export default {
  name: "App",
  computed: {
    isAuthenticated() {
      return this.$store.getters["security/isAuthenticated"]
    },
  },
  created() {
    let isAuthenticated = JSON.parse(this.$parent.$el.attributes["data-is-authenticated"].value),
      user = JSON.parse(this.$parent.$el.attributes["data-user"].value);

    let payload = { isAuthenticated: isAuthenticated, user: user };
    this.$store.dispatch("security/onRefresh", payload);

    axios.interceptors.response.use(undefined, (err) => {
      return new Promise(() => {
        if (err.response.status === 401) {
          this.$router.push({path: "/login"})
        }
        throw err;
      });
    });
  },
}
</script>

And finally our store module security.js:

import SecurityAPI from "../api/security";

const AUTHENTICATING = "AUTHENTICATING",
  AUTHENTICATING_SUCCESS = "AUTHENTICATING_SUCCESS",
  AUTHENTICATING_ERROR = "AUTHENTICATING_ERROR",
  PROVIDING_DATA_ON_REFRESH_SUCCESS = "PROVIDING_DATA_ON_REFRESH_SUCCESS";

export default {
  namespaced: true,
  state: {
    isLoading: false,
    error: null,
    isAuthenticated: false,
    user: null
  },
  getters: {
    isLoading(state) {
      return state.isLoading;
    },
    hasError(state) {
      return state.error !== null;
    },
    error(state) {
      return state.error;
    },
    isAuthenticated(state) {
      return state.isAuthenticated;
    },
    hasRole(state) {
      return role => {
        return state.user.roles.indexOf(role) !== -1;
      }
    }
  },
  mutations: {
    [AUTHENTICATING](state) {
      state.isLoading = true;
      state.error = null;
      state.isAuthenticated = false;
      state.user = null;
    },
    [AUTHENTICATING_SUCCESS](state, user) {
      state.isLoading = false;
      state.error = null;
      state.isAuthenticated = true;
      state.user = user;
    },
    [AUTHENTICATING_ERROR](state, error) {
      state.isLoading = false;
      state.error = error;
      state.isAuthenticated = false;
      state.user = null;
    },
    [PROVIDING_DATA_ON_REFRESH_SUCCESS](state, payload) {
      state.isLoading = false;
      state.error = null;
      state.isAuthenticated = payload.isAuthenticated;
      state.user = payload.user;
    }
  },
  actions: {
    async login({commit}, payload) {
      commit(AUTHENTICATING);
      try {
        let response = await SecurityAPI.login(payload.login, payload.password);
        commit(AUTHENTICATING_SUCCESS, response.data);
        return response.data;
      } catch (error) {
        commit(AUTHENTICATING_ERROR, error);
        return null;
      }
    },
    onRefresh({commit}, payload) {
      commit(PROVIDING_DATA_ON_REFRESH_SUCCESS, payload);
    }
  }
}

So what's going on?

In the indexAction we check if a user is authenticated. If so, we also serializes it to JSON.

We give those data to our Vue.js application thanks to the attributes data-is-authenticated and data-user from the file base.html.twig.

Our App component will then use those data to automatically set the state of our store module security thanks to the mutation PROVIDING_DATA_ON_REFRESH_SUCCESS.

Final touch: we have to show the post creation form only to users who have the role ROLE_FOO:

Posts.vue:

<template>
  <div>
    <div class="row col">
      <h1>Posts</h1>
    </div>

    <div
      v-if="canCreatePost"
      class="row col"
    >
      <form>
        <div class="form-row">
          <div class="col-8">
            <input
              v-model="message"
              type="text"
              class="form-control"
            >
          </div>
          <div class="col-4">
            <button
              :disabled="message.length === 0 || isLoading"
              type="button"
              class="btn btn-primary"
              @click="createPost()"
            >
              Create
            </button>
          </div>
        </div>
      </form>
    </div>

    <div
      v-if="isLoading"
      class="row col"
    >
      <p>Loading...</p>
    </div>

    <div
      v-else-if="hasError"
      class="row col"
    >
      <div
        class="alert alert-danger"
        role="alert"
      >
        {{ error }}
      </div>
    </div>

    <div
      v-else-if="!hasPosts"
      class="row col"
    >
      No posts!
    </div>

    <div
      v-for="post in posts"
      v-else
      :key="post.id"
      class="row col"
    >
      <post :message="post.message" />
    </div>
  </div>
</template>

<script>
import Post from "../components/Post";

export default {
  name: "Posts",
  components: {
    Post
  },
  data() {
    return {
      message: ""
    };
  },
  computed: {
    isLoading() {
      return this.$store.getters["post/isLoading"];
    },
    hasError() {
      return this.$store.getters["post/hasError"];
    },
    error() {
      return this.$store.getters["post/error"];
    },
    hasPosts() {
      return this.$store.getters["post/hasPosts"];
    },
    posts() {
      return this.$store.getters["post/posts"];
    },
    canCreatePost() {
      return this.$store.getters["security/hasRole"]("ROLE_FOO");
    }
  },
  created() {
    this.$store.dispatch("post/findAll");
  },
  methods: {
    async createPost() {
      const result = await this.$store.dispatch("post/create", this.$data.message);
      if (result !== null) {
        this.$data.message = "";
      }
    }
  }
};
</script>
  • we added the function canCreatePost which checks if the current user has the role ROLE_FOO

So what's next? Currently, when an exception occurs in the backend, we directly display a weird JSON to the user.

But that's not a good practice: let's see how to handle backend exceptions correctly in the next section!

Handling backend exceptions

There are actually two kinds of exceptions:

  • the ones occurring due to a coding error: they should fail loud and display an error page
  • the ones occurring due to a user error: they should inform the user about what's wrong and be displayed nicely in our Vue.js application

The first kind mostly returns a 500 Internal Server Error. So let's update our App.vue to handle those exceptions:

<script>
import axios from "axios";

export default {
  name: "App",
  computed: {
    isAuthenticated() {
      return this.$store.getters["security/isAuthenticated"]
    },
  },
  created() {
    let isAuthenticated = JSON.parse(this.$parent.$el.attributes["data-is-authenticated"].value),
      user = JSON.parse(this.$parent.$el.attributes["data-user"].value);

    let payload = { isAuthenticated: isAuthenticated, user: user };
    this.$store.dispatch("security/onRefresh", payload);

    axios.interceptors.response.use(undefined, (err) => {
      return new Promise(() => {
        if (err.response.status === 401) {
          this.$router.push({path: "/login"})
        } else if (err.response.status === 500) {
          document.open();
          document.write(err.response.data);
          document.close();
        }
        throw err;
      });
    });
  },
}
</script>

When a 500 status code is intercepted by axios, we simply replace our HTML with the error page. If development, Symfony will indeed display a nice stack trace which is useful for us, developers. If production, a basic error page will be shown.

Next, let's create a component to display the second kind of exceptions:

├── assets/
│   ├── vue/
│   │   ├── components/
│   │   │   ├── ErrorMessage.vue

ErrorMessage.vue:

<template>
  <div
    class="alert alert-danger"
    role="alert"
  >
    {{ error.response.data.error }}
  </div>
</template>

<script>
export default {
  name: "ErrorMessage",
  props: {
    error: {
      type: Error,
      required: true
    }
  },
}
</script>

And updates our Posts.vue and Login.vue components in order to use this new component:

Posts.vue:

<template>
  <div>
    <div class="row col">
      <h1>Posts</h1>
    </div>

    <div
      v-if="canCreatePost"
      class="row col"
    >
      <form>
        <div class="form-row">
          <div class="col-8">
            <input
              v-model="message"
              type="text"
              class="form-control"
            >
          </div>
          <div class="col-4">
            <button
              :disabled="message.length === 0 || isLoading"
              type="button"
              class="btn btn-primary"
              @click="createPost()"
            >
              Create
            </button>
          </div>
        </div>
      </form>
    </div>

    <div
      v-if="isLoading"
      class="row col"
    >
      <p>Loading...</p>
    </div>

    <div
      v-else-if="hasError"
      class="row col"
    >
      <error-message :error="error" />
    </div>

    <div
      v-else-if="!hasPosts"
      class="row col"
    >
      No posts!
    </div>

    <div
      v-for="post in posts"
      v-else
      :key="post.id"
      class="row col"
    >
      <post :message="post.message" />
    </div>
  </div>
</template>

<script>
import Post from "../components/Post";
import ErrorMessage from "../components/ErrorMessage";

export default {
  name: "Posts",
  components: {
    Post,
    ErrorMessage
  },
  data() {
    return {
      message: ""
    };
  },
  computed: {
    isLoading() {
      return this.$store.getters["post/isLoading"];
    },
    hasError() {
      return this.$store.getters["post/hasError"];
    },
    error() {
      return this.$store.getters["post/error"];
    },
    hasPosts() {
      return this.$store.getters["post/hasPosts"];
    },
    posts() {
      return this.$store.getters["post/posts"];
    },
    canCreatePost() {
      return this.$store.getters["security/hasRole"]("ROLE_FOO");
    }
  },
  created() {
    this.$store.dispatch("post/posts");
  },
  methods: {
    async createPost() {
      const result = await this.$store.dispatch("post/create", this.$data.message);
      if (result !== null) {
        this.$data.message = "";
      }
    }
  }
};
</script>

Login.vue:

<template>
  <div>
    <div class="row col">
      <h1>Login</h1>
    </div>

    <div class="row col">
      <form>
        <div class="form-row">
          <div class="col-4">
            <input
              v-model="login"
              type="text"
              class="form-control"
            >
          </div>
          <div class="col-4">
            <input
              v-model="password"
              type="password"
              class="form-control"
            >
          </div>
          <div class="col-4">
            <button
              :disabled="login.length === 0 || password.length === 0 || isLoading"
              type="button"
              class="btn btn-primary"
              @click="performLogin()"
            >
              Login
            </button>
          </div>
        </div>
      </form>
    </div>

    <div
      v-if="isLoading"
      class="row col"
    >
      <p>Loading...</p>
    </div>

    <div
      v-else-if="hasError"
      class="row col"
    >
      <error-message :error="error" />
    </div>
  </div>
</template>

<script>
import ErrorMessage from "../components/ErrorMessage";

export default {
  name: "Login",
  components: {
    ErrorMessage,
  },
  data() {
    return {
      login: "",
      password: ""
    };
  },
  computed: {
    isLoading() {
      return this.$store.getters["security/isLoading"];
    },
    hasError() {
      return this.$store.getters["security/hasError"];
    },
    error() {
      return this.$store.getters["security/error"];
    }
  },
  created() {
    let redirect = this.$route.query.redirect;

    if (this.$store.getters["security/isAuthenticated"]) {
      if (typeof redirect !== "undefined") {
        this.$router.push({path: redirect});
      } else {
        this.$router.push({path: "/home"});
      }
    }
  },
  methods: {
    async performLogin() {
      let payload = {login: this.$data.login, password: this.$data.password},
        redirect = this.$route.query.redirect;

      await this.$store.dispatch("security/login", payload);
      if (!this.$store.getters["security/hasError"]) {
        if (typeof redirect !== "undefined") {
          this.$router.push({path: redirect});
        } else {
          this.$router.push({path: "/home"});
        }
      }
    }
  }
}
</script>

Aaaand we're done! At least for this tutorial. There is a room for some improvements, as details below.

Improvements

Docker Compose

The image thecodingmachine/php:7.3-v2-apache-node10 allows us to launch commands on container startup using the STARTUP_COMMAND_XXX environment variables.

In our case, we can do something like:

  app:
    image: thecodingmachine/php:7.3-v2-apache-node10
    # ...
    environment:
      # ...
      STARTUP_COMMAND_1: composer install && bin/console doctrine:migrations:migrate --no-interaction
      STARTUP_COMMAND_2: yarn install && yarn watch &
    # ...

If one of your co-worker installs the project, he'll just have to run docker-compose up -d and his environment will automatically be ready!

CSRF

See https://symfony.com/doc/current/security/csrf.html

Alternative

You could put your Symfony API and Vue.js application in two containers.

Respectively, a PHP container and a Node.js container.

It means that Symfony will not be your main entry point anymore, but it actually will be the role of Node.js to serve your application.

Be careful so, you'll encounter some CORS issues!

Conclusion

I hope this tutorial will help you building nice SPA with Symfony and Vue.js.

If you have any issues, please fill them on the Github repository.

Have fun! ☺