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.



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

Before going further, we also have to update the .env file by replacing the line:

DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name

With:

DATABASE_URL=mysql://foo:bar@mysql:3306/foo

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.

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@^14 vue-template-compiler

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

let 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 your JavaScript imports CSS.
     */
    .addEntry('app', './assets/vue/index.js')
    //.addEntry('page1', './assets/js/page1.js')
    //.addEntry('page2', './assets/js/page2.js')

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

  • 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\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

final class IndexController extends Controller
{
    /**
     * @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 solution!

First, we have to update the indexAction from the IndexController.php:

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

Then our base.html.twig template:

<div id="app" data-vue-routing="{{ vueRouting }}"></div>

And finally our App.vue component:

import router from './router';

export default {
    name: 'app',
    beforeMount () {
        let vueRouting = this.$parent.$el.attributes['data-vue-routing'].value;
        router.push({path: vueRouting});
    },
}

So what's going on?

In the indexAction, we've defined an optional parameter vueRouting which contains for example home if you've requested the path /home.

We give this information to our Vue.js application thanks to the attribute data-vue-routing from the div with id app in the file base.html.twig

Our App component will then use this information to automatically load the correct page thanks to the instruction router.push({path: vueRouting}).

You may now wonder how does it work for query parameters. It's actually quite the same:

IndexController.php:

use Symfony\Component\HttpFoundation\Request;

// ...

/**
 * @Route("/{vueRouting}", name="index")
 * @param Request $request
 * @param null|string $vueRouting
 * @return Response
 */
public function indexAction(Request $request, ?string $vueRouting = null): Response
{
    return $this->render('base.html.twig', [
        'vueRouting' => \is_null($vueRouting) ? '/' : '/' . $vueRouting,
        'queryParameters' => \json_encode($request->query->all()),
    ]);
}

base.html.twig:

<div id="app" data-vue-routing="{{ vueRouting }}" data-query-parameters="{{ queryParameters }}"></div>

App.vue:

import router from './router';

export default {
    name: 'app',
    beforeMount () {
        let vueRouting = this.$parent.$el.attributes['data-vue-routing'].value,
            queryParameters = JSON.parse(this.$parent.$el.attributes['data-query-parameters'].value);

        router.push({path: vueRouting, query: queryParameters});
    },
}

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
 */
final 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
$ composer require symfony/serializer

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 Post[]
     */
    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\Controller;
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 Controller
{
    /** @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->create($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")
 * @param Request $request
 * @param null|string $vueRouting
 * @return Response
 */
public function indexAction(Request $request, ?string $vueRouting = null): Response
{
    return $this->render('base.html.twig', [
        'vueRouting' => \is_null($vueRouting) ? '/' : '/' . $vueRouting,
        'queryParameters' => \json_encode($request->query->all()),
    ]);
}

By adding the attribute requirements={"vueRouting"="^(?!api|_(profiler|wdt)).+"} to our route, it will not handle anymore routes beginning with /api. Please not 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
 */
final 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
     */
    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 (!$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 }

We now 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! ☺

Now we have 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

            logout_on_user_change: true

            # 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%'
        gc_maxlifetime: 300

    #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
  • gc_maxlifetime is the sessions duration in seconds (here 5 minutes)

We may now continue with our security API endpoints:

ApiSecurityController.php:

<?php

namespace App\Controller;

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

final class ApiSecurityController extends Controller
{
    /**
     * @Route("/api/security/login", name="login")
     * @return JsonResponse
     */
    public function loginAction(): JsonResponse
    {
        $securityCookie = new Cookie('authenticated', true, \time() + \intval(\ini_get('session.gc_maxlifetime')), '/', null, false, false);

        $response = new JsonResponse('authenticated!');
        $response->headers->setCookie($securityCookie);

        return $response;
    }

    /**
     * @Route("/api/security/logout", name="logout")
     * @throws \Exception
     */
    public function logoutAction()
    {
        throw new \Exception('This should not be reached!');
    }
}
It's important to set the last parameter of the Cookie constructor to false. By default HTTP only is set to true: with this value our frontend cannot access the cookie.

As we have set a cookie on authentication success, we must also unset it on logout:

LogoutHandler.php:

<?php

namespace App\Security;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Logout\LogoutHandlerInterface;

final class LogoutHandler implements LogoutHandlerInterface
{
    /**
     * @param Request $request
     * @param Response $response
     * @param TokenInterface $token
     * @return void
     */
    public function logout(Request $request, Response $response, TokenInterface $token): void
    {
        $response->headers->clearCookie('authenticated');
    }
}

services.yaml:

# ...

app.logout.handler:
  class: App\Security\LogoutHandler

security.yaml:

main:
    # ...
    logout:
        # ...
        handlers: [app.logout.handler]

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

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
    # ...

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 refreshes the page /posts and he's already authenticated, we should not redirect him to the login page
  3. if an Ajax call returns a 403 HTTP code, we should redirect the user 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,
    },
    getters: {
        isLoading (state) {
            return state.isLoading;
        },
        hasError (state) {
            return state.error !== null;
        },
        error (state) {
            return state.error;
        },
        isAuthenticated (state) {
            state.isAuthenticated = document.cookie.indexOf('authenticated') !== -1;
            return state.isAuthenticated;
        },
    },
    mutations: {
        ['AUTHENTICATING'](state) {
            state.isLoading = true;
            state.error = null;
            state.isAuthenticated = false;
        },
        ['AUTHENTICATING_SUCCESS'](state) {
            state.isLoading = false;
            state.error = null;
            state.isAuthenticated = true;
        },
        ['AUTHENTICATING_ERROR'](state, error) {
            state.isLoading = false;
            state.error = error;
            state.isAuthenticated = false;
        },
    },
    actions: {
        login ({commit}, payload) {
            commit('AUTHENTICATING');
            return SecurityAPI.login(payload.login, payload.password)
                .then(() => commit('AUTHENTICATING_SUCCESS'))
                .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: '',
            };
        },
        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>
    import axios from 'axios';
    import router from './router';

    export default {
        name: 'app',
        beforeMount () {
            let vueRouting = this.$parent.$el.attributes['data-vue-routing'].value,
                queryParameters = JSON.parse(this.$parent.$el.attributes['data-query-parameters'].value);

            router.push({path: vueRouting, query: queryParameters});
        },
        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 last 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';
    import router from './router';

    export default {
        name: 'app',
        beforeMount () {
            let vueRouting = this.$parent.$el.attributes['data-vue-routing'].value,
                queryParameters = JSON.parse(this.$parent.$el.attributes['data-query-parameters'].value);

            router.push({path: vueRouting, query: queryParameters});
        },
        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.

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-v1-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-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
      STARTUP_COMMAND_1: composer install && bin/console doctrine:migrations:migrate --no-interaction && bin/console doctrine:fixtures:load --no-interaction
      STARTUP_COMMAND_2: yarn install && yarn watch &
    volumes:
      - ./app:/var/www/html:rw

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!

User roles

Currently, we're only checking if the user is authenticated. But you may want to check if a user has some roles too!

It means that you should probably update the security module to store an information about that.

CSRF

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

Typescript

At the time of writing, using Typescript and Vue.js decorator are not possible with Webpack Encore.

Indeed, it requires Webpack 4, while Webpack Encore uses Webpack 3.

However it will be a real improvement, because Typescript provides optional static typing, classes and interfaces.

Errors

Currently, our Vue.js application displays directly the error returned by Symfony.

In a real world application, you should return nice errors and print them using for example notifications (https://github.com/euvl/vue-notification).

Alternative

You could totally 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! ☺