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:
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
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



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 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
├── app/
├── 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 services folder which contains the configuration files of my containers
  • an app folder with my application source code

Docker Compose

Let's start 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.2-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
    volumes:
      - ./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: admin
      MYSQL_DATABASE: foo
      MYSQL_USER: foo
      MYSQL_PASSWORD: bar
    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: foo
      PMA_PASSWORD: bar

volumes:

  mysql_data:
    driver: local

You may 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 can put directly those values in our docker-compose.yml file:

  app:
    image: thecodingmachine/php:7.2-v1-apache-node10
    labels:
      - traefik.enable=true
      - traefik.backend=app
      - traefik.frontend.rule=Host:app.localhost
    environment:
      APACHE_DOCUMENT_ROOT: public/
      PHP_EXTENSION_XDEBUG: 1
      # Symfony
      APP_ENV: dev
      APP_SECRET: 8d2a5c935d8ef1c0e2b751147382bc75
      DATABASE_URL: mysql://foo:bar@mysql:3306/foo # mysql://db_user:db_password@127.0.0.1:3306/db_name
    volumes:
      - ./app:/var/www/html:rw

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.

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

phpcs.xml.dist:

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

<ruleset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/squizlabs/php_codesniffer/phpcs.xsd">

    <arg name="basepath" value="."/>
    <arg name="cache" value=".phpcs-cache"/>
    <arg name="colors"/>
    <arg name="extensions" value="php"/>

    <rule ref="PSR2"/>

    <file>src/</file>
    <file>tests/</file>

    <rule ref="Generic.Files.LineLength">
        <properties>
          <property name="lineLimit" value="300"/>
          <property name="absoluteLineLimit" value="500"/>
        </properties>
     </rule>
</ruleset>

Reference the PHP_CodeSniffer scripts in our composer.json file:

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

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

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 reference those 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

And in our composer.json file:

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

As this command may consume some memory, we have also to update our PHP memory limit:

docker-compose.yml:

  app:
    # ...
    environment:
      PHP_MEMORY_LIMIT: 1G
      # ...

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 webpack-encore
$ 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 --dev vue vue-loader vue-template-compiler

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

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

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')

    // 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())
    .enableVueLoader()

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

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

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

module.exports = Encore.getWebpackConfig();

Here we have:

  • replaced .enableSingleRuntimeChunk() with .disableSingleRuntimeChunk()
  • added .enableVueLoader() after .enableVersioning(Encore.isProduction())
  • replaced the entry .addEntry('app', './assets/js/app.js') with .addEntry('app', './assets/vue/index.js') (./assets/vue/index.js being the location of our Vue.js application entry point)

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({
    template: '<App/>',
    components: { 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 file is located at public/build/app.js.

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

<!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.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
    </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.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
        <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
        <script src="{{ asset('build/app.js') }}"></script>
    </body>
</html>
  • <div id="app"></div> is where our frontend application will be injected
  • asset('build/app.js') is a Symfony shortcut to find a built asset thanks to a manifest.json file created by Webpack Encore. It reference in our case 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.
  • 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

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 file build/app.js 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 --dev 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({
    template: '<App/>',
    components: { 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"></span>
            </button>
            <div class="collapse navbar-collapse" id="navbarNav">
                <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></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 shoud allow user to post messages.

So let's start by creating a Post entity.

We're using the Carbon library for managing the datetime values of this entity. Run composer require nesbot/carbon to install it!

Post.php:

<?php

namespace App\Entity;

use Carbon\Carbon;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="post")
 * @ORM\HasLifecycleCallbacks
 */
class Post
{
    /**
     * @var int
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     * @ORM\Column(type="integer")
     */
    private $id;

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

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

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

    /**
     * @ORM\PrePersist
     * @return void
     */
    public function onPrePersist(): void
    {
        $this->created = Carbon::now();
    }

    /**
     * @ORM\PreUpdate
     * @return void
     */
    public function onPreUpdate(): void
    {
        $this->updated = Carbon::now();
    }

    /**
     * @return int
     */
    public function getId(): int
    {
        return $this->id;
    }

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

    /**
     * @param string $message
     * @return void
     */
    public function setMessage(string $message): void
    {
        $this->message = $message;
    }

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

    /**
     * @return \DateTime|null
     */
    public function getUpdated(): ?\DateTime
    {
        return $this->updated;
    }
}

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

For this purpose, we install the DoctrineMigrations bundle:

$ composer require doctrine/doctrine-migrations-bundle "^1.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 post 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 service which handles the logic of our API:

PostService.php:

<?php

namespace App\Service;

use App\Entity\Post;
use Doctrine\ORM\EntityManagerInterface;

final class PostService
{
    /** @var EntityManagerInterface */
    private $em;

    /**
     * PostService constructor.
     * @param EntityManagerInterface $em
     */
    public function __construct(EntityManagerInterface $em)
    {
        $this->em = $em;
    }

    /**
     * @param string $message
     * @return Post
     */
    public function createPost(string $message): Post
    {
        $postEntity = new Post();
        $postEntity->setMessage($message);
        $this->em->persist($postEntity);
        $this->em->flush();

        return $postEntity;
    }

    /**
     * @return object[]
     */
    public function getAll(): array
    {
        return $this->em->getRepository(Post::class)->findBy([], ['id' => 'DESC']);
    }
}

And the related controller with our API endpoints:

ApiPostController.php:

<?php

namespace App\Controller;

use App\Service\PostService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use FOS\RestBundle\Controller\Annotations as Rest;
use Symfony\Component\Serializer\SerializerInterface;

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

    /** @var PostService */
    private $postService;

    /**
     * ApiPostController constructor.
     * @param SerializerInterface $serializer
     * @param PostService $postService
     */
    public function __construct(SerializerInterface $serializer, PostService $postService)
    {
        $this->serializer = $serializer;
        $this->postService = $postService;
    }

    /**
     * @Rest\Post("/api/post/create", name="createPost")
     * @param Request $request
     * @return JsonResponse
     */
    public function createAction(Request $request): JsonResponse
    {
        $message = $request->request->get('message');
        $postEntity = $this->postService->createPost($message);
        $data = $this->serializer->serialize($postEntity, 'json');

        return new JsonResponse($data, 200, [], true);
    }

    /**
     * @Rest\Get("/api/posts", name="getAllPosts")
     * @return JsonResponse
     */
    public function getAllActions(): JsonResponse
    {
        $postEntities = $this->postService->getAll();
        $data = $this->serializer->serialize($postEntities, 'json');

        return new JsonResponse($data, 200, [], 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.

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 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 --dev 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/post/create',
            {
                message: message,
            }
        );
    },
    getAll () {
        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 --dev 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';

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: {
        createPost ({commit}, message) {
            commit('CREATING_POST');
            return PostAPI.create(message)
                .then(res => commit('CREATING_POST_SUCCESS', res.data))
                .catch(err => commit('CREATING_POST_ERROR', err));
        },
        fetchPosts ({commit}) {
            commit('FETCHING_POSTS');
            return PostAPI.getAll()
                .then(res => commit('FETCHING_POSTS_SUCCESS', res.data))
                .catch(err => commit('FETCHING_POSTS_ERROR', err));
        },
    },
}

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({
    template: '<App/>',
    components: { 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 @click="createPost()" :disabled="message.length === 0 || isLoading" type="button" class="btn btn-primary">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-else v-for="post in posts" class="row col">
            <post :message="post.message"></post>
        </div>
    </div>
</template>

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

    export default {
        name: 'posts',
        components: {
            Post
        },
        data () {
            return {
                message: '',
            };
        },
        created () {
            this.$store.dispatch('post/fetchPosts');
        },
        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'];
            },
        },
        methods: {
            createPost () {
                this.$store.dispatch('post/createPost', this.$data.message)
                    .then(() => 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'],
    }
</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"></span>
            </button>
            <div class="collapse navbar-collapse" id="navbarNav">
                <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></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

namespace App\Entity;

use Carbon\Carbon;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity
 * @ORM\Table(name="user")
 * @ORM\HasLifecycleCallbacks
 */
class User implements UserInterface
{
    /**
     * @var int
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     * @ORM\Column(type="integer")
     */
    private $id;

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

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

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

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

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

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

    /**
     * User constructor.
     */
    public function __construct()
    {
        $this->roles = [];
    }

    /**
     * @ORM\PrePersist
     * @return void
     */
    public function onPrePersist(): void
    {
        $this->created = Carbon::now();
    }

    /**
     * @ORM\PreUpdate
     * @return void
     */
    public function onPreUpdate(): void
    {
        $this->updated = Carbon::now();
    }

    /**
     * @return int
     */
    public function getId(): int
    {
        return $this->id;
    }

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

    /**
     * @param string $login
     * @return void
     */
    public function setLogin(string $login): void
    {
        $this->login = $login;
    }

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

    /**
     * @return string|null
     */
    public function getPlainPassword(): ?string
    {
        return $this->plainPassword;
    }

    /**
     * @param string $password
     */
    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;
    }

    /**
     * @return string|null
     */
    public function getPassword(): ?string
    {
        return $this->password;
    }

    /**
     * @param string $password
     * @return void
     */
    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
     * @return void
     */
    public function setRoles(array $roles): void
    {
        $this->roles = $roles;
    }

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

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

    /**
     * @return \DateTime|null
     */
    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

namespace App\Security;

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

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

    /**
     * HashPasswordListener constructor.
     * @param UserPasswordEncoderInterface $passwordEncoder
     */
    public function __construct(UserPasswordEncoderInterface $passwordEncoder)
    {
        $this->passwordEncoder = $passwordEncoder;
    }

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

        $this->encodePassword($entity);
    }

    /**
     * @param LifecycleEventArgs $args
     * @return void
     */
    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'];
    }

    /**
     * @param User $entity
     * @return void
     */
    private function encodePassword(User $entity): void
    {
        if (\is_null($entity->getPlainPassword())) {
            return;
        }

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

        $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: bcrypt
    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 (bcrypt)
  • 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

Also replace the content of 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: session.handler.native_file
        save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%'
        cookie_secure: auto
        cookie_samesite: lax

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

    cache:
        # Put the unique name of your app here: the prefix seed
        # is used to compute stable namespaces for cache keys.
        #prefix_seed: your_vendor_name/app_name

        # The app cache caches to the filesystem by default.
        # Other options include:

        # Redis
        #app: cache.adapter.redis
        #default_redis_provider: redis://localhost

        # APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
        #app: cache.adapter.apcu
  • we added a new handler: session.handler.native_file
  • this handler will save sessions in files under var/sessions/dev

We may now continue with our security API endpoints:

ApiSecurityController.php:

<?php

namespace App\Controller;

use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

final class ApiSecurityController extends AbstractController
{
    /**
     * @Route("/api/security/login", name="login")
     * @return JsonResponse
     */
    public function loginAction(): JsonResponse
    {
        /** @var User $user */
        $user = $this->getUser();
        $response = new JsonResponse($user->getRoles());
        return $response;
    }

    /**
     * @Route("/api/security/logout", name="logout")
     * @return void
     * @throws \RuntimeException
     */
    public function logoutAction(): void
    {
        throw new \RuntimeException('This should not be reached!');
    }
}

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

ApiPostController.php:

/**
 * Class ApiPostController
 * @package App\Controller
 * @IsGranted("IS_AUTHENTICATED_FULLY")
 */
final class ApiPostController extends Controller

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

ApiPostController.php:

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

Important: due to an issue from Symfony, an access denied will throw a 500 HTTP error (see https://github.com/symfony/symfony/issues/25806).

In order to fix it, we must create a file named AuthenticationEntryPoint.php to return a correct 403 HTTP code:

<?php declare(strict_types=1);

namespace App\Security;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;

/**
 * This is required because otherwise symfony would throw HTTP 500 response,
 * when anonymous user try to access user protected route.
 * For some reason in @see JsonLoginFactory::createListener
 * entiry point is not defined likein @see FormLoginFactory::createEntryPoint
 * and it defaults to null
 * When it default to null @see InsufficientAuthenticationException
 * is being created, and in case of null entry point thrown here @see ExceptionListener::startAuthentication
 *
 * if (null === $this->authenticationEntryPoint) {
 *     throw $authException; // instance of: @see InsufficientAuthenticationException
 * }
 *
 * there are many issue ticket for this, dating back to 2013 so maybe some day it would be fixed:
 * @link https://github.com/symfony/symfony/issues/8467
 * @link https://github.com/symfony/symfony/issues/25806
 * @link https://github.com/symfony/symfony/issues/20233
 * @link https://github.com/Rebolon/php-sf-flex-webpack-encore-vuejs/issues/31
 */
final class AuthenticationEntryPoint implements AuthenticationEntryPointInterface
{
    public function start(Request $request, AuthenticationException $authException = null): JsonResponse
    {
        return new JsonResponse([], Response::HTTP_FORBIDDEN);
    }
}

And update our configuration file security.yaml with:

main:
    entry_point: App\Security\AuthenticationEntryPoint
    # ...

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

Let's begin by installing the DoctrineFixtures bundle:

$ composer require --dev doctrine/doctrine-fixtures-bundle

Then create our user fixtures:

UserFixtures.php:

<?php

namespace App\DataFixtures;

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

final class UserFixtures extends Fixture
{
    /**
     * @param ObjectManager $manager
     * @return void
     */
    public function load(ObjectManager $manager): void
    {
        $userEntity = new User();
        $userEntity->setLogin('foo');
        $userEntity->setPlainPassword('bar');
        $userEntity->setRoles(['ROLE_FOO']);
        $manager->persist($userEntity);
        $manager->flush();
    }
}

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

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 403 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';

export default {
    namespaced: true,
    state: {
        isLoading: false,
        error: null,
        isAuthenticated: false,
        roles: [],
    },
    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.roles.indexOf(role) !== -1;
            }
        },
    },
    mutations: {
        ['AUTHENTICATING'](state) {
            state.isLoading = true;
            state.error = null;
            state.isAuthenticated = false;
            state.roles = [];
        },
        ['AUTHENTICATING_SUCCESS'](state, roles) {
            state.isLoading = false;
            state.error = null;
            state.isAuthenticated = true;
            state.roles = roles;
        },
        ['AUTHENTICATING_ERROR'](state, error) {
            state.isLoading = false;
            state.error = error;
            state.isAuthenticated = false;
            state.roles = [];
        },
    },
    actions: {
        login ({commit}, payload) {
            commit('AUTHENTICATING');
            return SecurityAPI.login(payload.login, payload.password)
                .then(res => commit('AUTHENTICATING_SUCCESS', res.data))
                .catch(err => commit('AUTHENTICATING_ERROR', err));
        },
    },
}

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 @click="performLogin()" :disabled="login.length === 0 || password.length === 0 || isLoading" type="button" class="btn btn-primary">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: '',
            };
        },
        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'});
                }
            }
        },
        computed: {
            isLoading () {
                return this.$store.getters['security/isLoading'];
            },
            hasError () {
                return this.$store.getters['security/hasError'];
            },
            error () {
                return this.$store.getters['security/error'];
            },
        },
        methods: {
            performLogin () {
                let payload = { login: this.$data.login, password: this.$data.password },
                    redirect = this.$route.query.redirect;

                this.$store.dispatch('security/login', payload)
                    .then(() => {
                        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"></span>
            </button>
            <div class="collapse navbar-collapse" id="navbarNav">
                <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 class="nav-item" v-if="isAuthenticated">
                        <a class="nav-link" href="/api/security/logout">Logout</a>
                    </li>
                </ul>
            </div>
        </nav>

        <router-view></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 403 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',
         created () {
            axios.interceptors.response.use(undefined, (err) => {
                return new Promise(() => {
                    if (err.response.status === 403) {
                        this.$router.push({path: '/login'})
                    }
                    throw err;
                });
            });
        },
        computed: {
            isAuthenticated () {
                return this.$store.getters['security/isAuthenticated']
            },
        },
    }
</script>
  • we added the created attribute where we're telling axios that for any Ajax calls which returns a 403 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:

/**
 * @Route("/{vueRouting}", requirements={"vueRouting"="^(?!api|_(profiler|wdt)).*"}, name="index")
 * @return Response
 * @throws JsonException
 */
public function indexAction(): Response
{
    /** @var User $user */
    $user = $this->getUser();
    return $this->render('base.html.twig', [
        'isAuthenticated' => json_encode(!empty($user)),
        'roles' => json_encode(!empty($user) ? $user->getRoles() : []),
    ]);
}

Then our base.html.twig:

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

Then our App.vue:

<script>
    import axios from 'axios';

    export default {
        name: 'app',
        created () {
            let isAuthenticated = JSON.parse(this.$parent.$el.attributes['data-is-authenticated'].value),
                roles = JSON.parse(this.$parent.$el.attributes['data-roles'].value);

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

            axios.interceptors.response.use(undefined, (err) => {
                return new Promise(() => {
                    if (err.response.status === 403) {
                        this.$router.push({path: '/login'})
                    }
                    throw err;
                });
            });
        },
        computed: {
            isAuthenticated () {
                return this.$store.getters['security/isAuthenticated']
            },
        },
    }
</script>

And finally our store module security.js:

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

export default {
    namespaced: true,
    state: {
        isLoading: false,
        error: null,
        isAuthenticated: false,
        roles: [],
    },
    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.roles.indexOf(role) !== -1;
            }
        },
    },
    mutations: {
        ['AUTHENTICATING'](state) {
            state.isLoading = true;
            state.error = null;
            state.isAuthenticated = false;
            state.roles = [];
        },
        ['AUTHENTICATING_SUCCESS'](state, roles) {
            state.isLoading = false;
            state.error = null;
            state.isAuthenticated = true;
            state.roles = roles;
        },
        ['AUTHENTICATING_ERROR'](state, error) {
            state.isLoading = false;
            state.error = error;
            state.isAuthenticated = false;
            state.roles = [];
        },
        ['PROVIDING_DATA_ON_REFRESH_SUCCESS'](state, payload) {
            state.isLoading = false;
            state.error = null;
            state.isAuthenticated = payload.isAuthenticated;
            state.roles = payload.roles;
        },
    },
    actions: {
        login ({commit}, payload) {
            commit('AUTHENTICATING');
            return SecurityAPI.login(payload.login, payload.password)
                .then(res => commit('AUTHENTICATING_SUCCESS', res.data))
                .catch(err => commit('AUTHENTICATING_ERROR', err));
        },
        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 retrieve its roles.

We give those data to our Vue.js application thanks to the attributes data-is-authenticated and data-roles 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 class="row col" v-if="canCreatePost">
            <form>
                <div class="form-row">
                    <div class="col-8">
                        <input v-model="message" type="text" class="form-control">
                    </div>
                    <div class="col-4">
                        <button @click="createPost()" :disabled="message.length === 0 || isLoading" type="button" class="btn btn-primary">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-else v-for="post in posts" class="row col">
            <post :message="post.message"></post>
        </div>
    </div>
</template>

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

    export default {
        name: 'posts',
        components: {
            Post
        },
        data () {
            return {
                message: '',
            };
        },
        created () {
            this.$store.dispatch('post/fetchPosts');
        },
        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');
            }
        },
        methods: {
            createPost () {
                this.$store.dispatch('post/createPost', this.$data.message)
                    .then(() => 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',
        created () {
            let isAuthenticated = JSON.parse(this.$parent.$el.attributes['data-is-authenticated'].value),
                roles = JSON.parse(this.$parent.$el.attributes['data-roles'].value);

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

            axios.interceptors.response.use(undefined, (err) => {
                return new Promise(() => {
                    if (err.response.status === 403) {
                        this.$router.push({path: '/login'})
                    } else if (err.response.status === 500) {
                        document.open();
                        document.write(err.response.data);
                        document.close();
                    }
                    throw err;
                });
            });
        },
        computed: {
            isAuthenticated () {
                return this.$store.getters['security/isAuthenticated']
            },
        },
    }
</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'],
    }
</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 class="row col" v-if="canCreatePost">
            <form>
                <div class="form-row">
                    <div class="col-8">
                        <input v-model="message" type="text" class="form-control">
                    </div>
                    <div class="col-4">
                        <button @click="createPost()" :disabled="message.length === 0 || isLoading" type="button" class="btn btn-primary">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-message :error="error"></error-message>
            </div>
        </div>

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

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

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

    export default {
        name: 'posts',
        components: {
            Post,
            ErrorMessage,
        },
        data () {
            return {
                message: '',
            };
        },
        created () {
            this.$store.dispatch('post/fetchPosts');
        },
        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');
            }
        },
        methods: {
            createPost () {
                this.$store.dispatch('post/createPost', this.$data.message)
                    .then(() => 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 @click="performLogin()" :disabled="login.length === 0 || password.length === 0 || isLoading" type="button" class="btn btn-primary">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"></error-message>
        </div>
    </div>
</template>

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

    export default {
        name: 'login',
        components: {
            ErrorMessage,
        },
        data () {
            return {
                login: '',
                password: '',
            };
        },
        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'});
                }
            }
        },
        computed: {
            isLoading () {
                return this.$store.getters['security/isLoading'];
            },
            hasError () {
                return this.$store.getters['security/hasError'];
            },
            error () {
                return this.$store.getters['security/error'];
            },
        },
        methods: {
            performLogin () {
                let payload = { login: this.$data.login, password: this.$data.password },
                    redirect = this.$route.query.redirect;

                this.$store.dispatch('security/login', payload)
                    .then(() => {
                        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.2-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.2-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! ☺