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.
Jun. 19th, 2019:
- Symfony 4.3 support
- PHP 7.3
- Improvements from the community
- Force Symfony to use environment variables instead of values from
.env
file
- 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
- Fixtures are now created after authentication configuration (issue #1)
- Symfony environment variables are now directly set in the file
docker-compose.yml
- PHP analysis tools section
A single-page application (SPA) is a web application or web site that interacts with the user by dynamically rewriting the current page rather than loading entire new pages from a server. This approach avoids interruption of the user experience between successive pages, making the application behave more like a desktop application.
-- Wikipedia
In others words, it's a lot easier to keep the context (data and so on) of our application in our frontend while navigating between our pages. If you have struggled with complex forms in advanced UI, you may understand the possibilities.
In this tutorial, we'll build a very simple application where users can post messages. We'll see how to:
- setup a development environment with Docker and Docker Compose
- create a Vue.js SPA using a router (Vue Router), a store (Vuex) and components
- create and test a REST API using Symfony 4 as the backend framework and consuming it with axios
- create an authentication system which works in a SPA context (using the JSON authentication from Symfony)
Development environment setup
Project structure
├── services/
│ ├── mysql/
│ │ ├── utf8mb4.cnf
├── sources/
│ ├── app/
├── .gitignore
├── .env.template
├── docker-compose.yml
This is usually the structure I use for my projects:
- a
docker-compose.yml
file with Traefik (reverse-proxy), PHP/Apache, MySQL and phpMyAdmin containers - a
.env.template
which contains the shared environment variables of the containers and secrets. - a
services
folder which contains the configuration files of my containers - a
sources/app
folder with my application source code
Docker Compose
Let's start with the content of the .env.template
file:
MYSQL_ROOT_PASSWORD=admin
MYSQL_DATABASE=tutorial
MYSQL_USER=foo
MYSQL_PASSWORD=bar
This file will help us to store the shared environment variables of our containers and secrets we don't want to commit.
As Docker Compose is only able to read those values from a file named .env
, we have to create this file from the previous template.
For instance:
$ cp .env.template .env
.env
to your .gitignore
file. Only the file .env.template
should be committed, with dummy values for your secrets.Let's continue with the content of the docker-compose.yml
file.
We begin by adding a reverse-proxy (Traefik here) which will redirect any incoming requests to a virtual host to the correct container:
version: '3.7'
services:
traefik:
image: traefik:1.7
command: --docker --docker.exposedbydefault=false
ports:
- "80:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
Next, we add our PHP/Apache container:
app:
image: thecodingmachine/php:7.3-v2-apache-node10
labels:
- traefik.enable=true
- traefik.backend=app
- traefik.frontend.rule=Host:app.localhost
environment:
APACHE_DOCUMENT_ROOT: "public/"
PHP_EXTENSION_XDEBUG: "1"
PHP_INI_MEMORY_LIMIT: "1G"
# Symfony
APP_ENV: "dev"
APP_SECRET: "8d2a5c935d8ef1c0e2b751147382bc75"
DATABASE_URL: "mysql://$MYSQL_USER:$MYSQL_PASSWORD@mysql:3306/$MYSQL_DATABASE"
volumes:
- ./sources/app:/var/www/html:rw
We're using here an image based on the official PHP image, but more complete for our developer needs:
- Composer with prestissimo
- Node.js with Yarn
- no more permission issues on Linux
- useful environment variables (for example: enable/disable PHP extensions)
- see here for the complete list of features
Also, we added some labels on this container. Those labels are used by our Traefik container to know on which virtual host a container is linked.
For instance, our PHP/Apache container is linked with the app.localhost
virtual host.
You'll need to update your /etc/hosts
file on MacOS (and the equivalent on Windows) by binding this
virtual host with your localhost IP (127.0.0.1).
Good? Let's continue with our MySQL and phpMyAdmin containers:
mysql:
image: mysql:5.7
environment:
MYSQL_ROOT_PASSWORD: "$MYSQL_ROOT_PASSWORD"
MYSQL_DATABASE: "$MYSQL_DATABASE"
MYSQL_USER: "$MYSQL_USER"
MYSQL_PASSWORD: "$MYSQL_PASSWORD"
volumes:
- mysql_data:/var/lib/mysql
- ./services/mysql/utf8mb4.cnf:/etc/mysql/conf.d/utf8mb4.cnf:ro
phpmyadmin:
image: phpmyadmin/phpmyadmin:4.7
labels:
- traefik.enable=true
- traefik.backend=phpmyadmin
- traefik.frontend.rule=Host:phpadmin.app.localhost
environment:
PMA_HOST: mysql
PMA_USER: "$MYSQL_USER"
PMA_PASSWORD: "$MYSQL_PASSWORD"
volumes:
mysql_data:
driver: local
As you can see, in a Docker context, the hostname of your database is actually the name of the MySQL service defined in
your docker-compose.yml
file.
You may also notice that we mount the file located at ./services/mysql/utf8mb4.cnf
in our MySQL container.
It's a simple MySQL configuration file to set utf8mb4 as the default encoding of our MySQL instance:
[client]
default-character-set = utf8mb4
[mysql]
default-character-set = utf8mb4
[mysqld]
character-set-client-handshake = FALSE
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
That's it! You may now start your stack by running:
$ docker-compose up -d
And start the shell of your PHP/Apache container with:
$ docker-compose exec app bash
Symfony
Nothing special here, just some Composer magic:
$ composer create-project symfony/website-skeleton .
$ composer require symfony/apache-pack
symfony/apache-pack
installs a .htaccess
file in the public
directory that contains the rewrite rules.Once done, we have to update the environment variables used by Symfony.
By default, Symfony will look at a file named .env
on a development environment.
But as we're using Docker, we have put directly those values in our docker-compose.yml
file.
So let's comment the following lines in config/boostrap.php
:
<?php
#use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
// Load cached env vars if the .env.local.php file exists
// Run "composer dump-env prod" to create it (requires symfony/flex >=1.2)
/*if (is_array($env = @include dirname(__DIR__).'/.env.local.php')) {
foreach ($env as $k => $v) {
$_ENV[$k] = $_ENV[$k] ?? (isset($_SERVER[$k]) && 0 !== strpos($k, 'HTTP_') ? $_SERVER[$k] : $v);
}
} elseif (!class_exists(Dotenv::class)) {
throw new RuntimeException('Please run "composer require symfony/dotenv" to load the ".env" files configuring the application.');
} else {
// load all the .env files
(new Dotenv(false))->loadEnv(dirname(__DIR__).'/.env');
}*/
$_SERVER += $_ENV;
$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null) ?: 'dev';
$_SERVER['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? 'prod' !== $_SERVER['APP_ENV'];
$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = (int) $_SERVER['APP_DEBUG'] || filter_var($_SERVER['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN) ? '1' : '0';
For our logs, we need to tell Symfony to send them to the stderr
.
Indeed, by default Symfony writes them into the folder var/log
. As we're using Docker, we may send them to the Docker output:
config/packages/dev/monolog.yaml
:
monolog:
handlers:
main:
type: stream
# Output logs to Docker stderr by default.
path: "php://stderr"
#path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
# uncomment to get logging in your browser
# you may have to allow bigger header sizes in your Web server configuration
#firephp:
# type: firephp
# level: info
#chromephp:
# type: chromephp
# level: info
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
config/packages/test/monolog.yaml
:
monolog:
handlers:
main:
type: stream
# Output logs to Docker stderr by default.
path: "php://stderr"
#path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
config/packages/prod/monolog.yaml
:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
nested:
type: stream
# Output logs to Docker stderr by default.
path: "php://stderr"
#path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.deprecations.log"
deprecation_filter:
type: filter
handler: deprecation
max_level: info
channels: ["php"]
PHP analysis tools
In order to make sure our code base is sane, we have to install a few tools.
Let's begin with PHP_CodeSniffer:
PHP_CodeSniffer is a set of two PHP scripts; the main phpcs script that tokenizes PHP, JavaScript and CSS files to detect violations of a defined coding standard, and a second phpcbf script to automatically correct coding standard violations. PHP_CodeSniffer is an essential development tool that ensures your code remains clean and consistent.
-- GitHub
$ composer require --dev squizlabs/php_codesniffer doctrine/coding-standard
doctrine/coding-standard
adds scricts rules to PHP_CodeSniffer.phpcs.xml.dist
:
<?xml version="1.0"?>
<ruleset>
<arg name="basepath" value="."/>
<arg name="extensions" value="php"/>
<arg name="parallel" value="16"/>
<arg name="colors"/>
<!-- Ignore warnings, show progress of the run and show sniff names -->
<arg value="nps"/>
<!-- Directories to be checked -->
<file>src</file>
<file>tests</file>
<exclude-pattern>tests/dependencies/*</exclude-pattern>
<!-- Include full Doctrine Coding Standard -->
<rule ref="Doctrine">
<exclude name="SlevomatCodingStandard.Classes.SuperfluousInterfaceNaming.SuperfluousSuffix"/>
<exclude name="SlevomatCodingStandard.Classes.SuperfluousExceptionNaming.SuperfluousSuffix"/>
<exclude name="SlevomatCodingStandard.Classes.SuperfluousAbstractClassNaming.SuperfluousPrefix"/>
<exclude name="Squiz.Commenting.FunctionComment.InvalidNoReturn" />
<exclude name="Generic.Formatting.MultipleStatementAlignment" />
</rule>
<!-- Do not align assignments -->
<rule ref="Generic.Formatting.MultipleStatementAlignment">
<severity>0</severity>
</rule>
<!-- Do not align comments -->
<rule ref="Squiz.Commenting.FunctionComment.SpacingAfterParamName">
<severity>0</severity>
</rule>
<rule ref="Squiz.Commenting.FunctionComment.SpacingAfterParamType">
<severity>0</severity>
</rule>
<!-- Require no space before colon in return types -->
<rule ref="SlevomatCodingStandard.TypeHints.ReturnTypeHintSpacing">
<properties>
<property name="spacesCountBeforeColon" value="0"/>
</properties>
</rule>
</ruleset>
Reference the PHP_CodeSniffer scripts in our composer.json
file:
"scripts": {
"csfix": "phpcbf --ignore=src/Migrations/**,src/Kernel.php",
"cscheck": "phpcs --ignore=src/Migrations/**,src/Kernel.php",
# ...
You may now run those scripts by using composer csfix
and composer cscheck
!
csfix
: this script will try to fix as many errors as possiblecscheck
: this script will checks potential errors in your code
Done? Ok, let's continue with PHPStan:
PHPStan focuses on finding errors in your code without actually running it. It catches whole classes of bugs even before you write tests for the code.
-- GitHub
$ composer require --dev phpstan/phpstan
A cool feature of PHPStan is its ability to use plugins (for additional rules).
And guess what, I know some of them which are useful!
First, TheCodingMachine strict rules (see our blog post for more details):
$ composer require --dev thecodingmachine/phpstan-strict-rules
And also Safe with its PHPStan rules:
A set of core PHP functions rewritten to throw exceptions instead of returning false when an error is encountered.
-- GitHub
$ composer require thecodingmachine/safe
$ composer require --dev thecodingmachine/phpstan-safe-rule
Let's not forget to ignore the same files as in PHP_CodeSniffer and include our custom rules in a file called phpstan.neon
:
includes:
- vendor/thecodingmachine/phpstan-strict-rules/phpstan-strict-rules.neon
- vendor/thecodingmachine/phpstan-safe-rule/phpstan-safe-rule.neon
parameters:
excludes_analyse:
- %currentWorkingDirectory%/src/Migrations/*.php
- %currentWorkingDirectory%/src/Kernel.php
Last but not least, reference the PHPStan script in our composer.json
file:
"scripts": {
"phpstan": "phpstan analyse src/ -c phpstan.neon --level=7 --no-progress -vvv --memory-limit=1024M",
# ...
Alright we're good for this part! Every time you push your modifications to your repository, don't forget to run composer csfix && composer cscheck && composer phpstan
!
Webpack
Webpack is a very useful tool for building our frontend dependencies/source code.
Symfony provides a simple abstraction for this tool which fits well in a Symfony context: Webpack Encore
$ composer require symfony/webpack-encore-bundle
$ yarn install
Those commands will create:
- an
assets
folder where belongs your frontend source code - a
webpack.config.js
file with Webpack Encore instructions - a
package.json
file which lists all your frontend dependencies - a
node_modules
folder which contains your frontend dependencies source code
In order to use Vue.js, we also have to install the following dependencies:
$ yarn add vue
$ yarn add --dev vue-loader vue-template-compiler @babel/plugin-transform-runtime @babel/runtime
@babel/plugin-transform-runtime
and @babel/runtime
will allow the use of async functions.And update our Webpack configuration file webpack.config.js
:
let Encore = require('@symfony/webpack-encore');
// Manually configure the runtime environment if not already configured yet by the "encore" command.
// It's useful when you use tools that rely on webpack.config.js file.
if (!Encore.isRuntimeEnvironmentConfigured()) {
Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
}
Encore
// directory where compiled assets will be stored
.setOutputPath('public/build/')
// public path used by the web server to access the output path
.setPublicPath('/build')
// only needed for CDN's or sub-directory deploy
//.setManifestKeyPrefix('build/')
/*
* ENTRY CONFIG
*
* Add 1 entry for each "page" of your app
* (including one that's included on every page - e.g. "app")
*
* Each entry will result in one JavaScript file (e.g. app.js)
* and one CSS file (e.g. app.css) if you JavaScript imports CSS.
*/
.addEntry('app', './assets/vue/index.js')
//.addEntry('page1', './assets/js/page1.js')
//.addEntry('page2', './assets/js/page2.js')
// When enabled, Webpack "splits" your files into smaller pieces for greater optimization.
.splitEntryChunks()
// will require an extra script tag for runtime.js
// but, you probably want this, unless you're building a single-page app
//.enableSingleRuntimeChunk()
.disableSingleRuntimeChunk()
/*
* FEATURE CONFIG
*
* Enable & configure other features below. For a full
* list of features, see:
* https://symfony.com/doc/current/frontend.html#adding-more-features
*/
.cleanupOutputBeforeBuild()
.enableBuildNotifications()
.enableSourceMaps(!Encore.isProduction())
// enables hashed filenames (e.g. app.abc123.css)
.enableVersioning(Encore.isProduction())
// enables @babel/preset-env polyfills
.configureBabel((babelConfig) => {
babelConfig.plugins.push('@babel/plugin-transform-runtime');
}, {
useBuiltIns: 'usage',
corejs: 3
})
// enables Vue.js support
.enableVueLoader()
// enables Sass/SCSS support
//.enableSassLoader()
// uncomment if you use TypeScript
//.enableTypeScriptLoader()
// uncomment to get integrity="..." attributes on your script & link tags
// requires WebpackEncoreBundle 1.4 or higher
.enableIntegrityHashes()
// uncomment if you're having problems with a jQuery plugin
//.autoProvidejQuery()
// uncomment if you use API Platform Admin (composer req api-admin)
//.enableReactPreset()
//.addEntry('admin', './assets/js/admin.js')
;
module.exports = Encore.getWebpackConfig();
Here we have:
- replaced the entry
.addEntry('app', './assets/js/app.js')
with.addEntry('app', './assets/vue/index.js')
(./assets/vue/index.js
being the future location of our Vue.js application entry point) - replaced
.enableSingleRuntimeChunk()
with.disableSingleRuntimeChunk()
- added
.enableVueLoader()
- uncommented
.enableIntegrityHashes()
- updated the Babel configuration with
@babel/plugin-transform-runtime
In your package.json
, add the following entry:
"browserslist": [
"> 0.5%",
"last 2 versions",
"Firefox ESR",
"not dead"
]
Good? Let's add ESLint for some formatting and code-quality rules:
$ yarn add --dev eslint eslint-loader eslint-plugin-vue babel-eslint
.eslintrc.json
:
{
"root": true,
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module",
"parser": "babel-eslint"
},
"env": {
"browser": true,
"es6": true
},
"extends": [
"eslint:recommended",
"plugin:vue/recommended"
],
"rules": {
"indent": ["error", 2]
}
}
webpack.config.js
:
// enable ESLint
.addLoader({
enforce: 'pre',
test: /\.(js|vue)$/,
loader: 'eslint-loader',
exclude: /node_modules/,
options: {
fix: true,
emitError: true,
emitWarning: true,
},
})
package.json
:
"scripts": {
"csfix": "eslint assets/vue --ext .js,.vue, --fix"
Our development environment is now ready! ☺
In the next section, we'll create our Vue.js application and see how to serve it with Symfony.
Vue.js
Let's start by removing all folders from the assets
folder. They have been created by Webpack Encore and we don't need them.
Once done, we may create our Vue.js application structure:
├── assets/
│ ├── vue/
│ │ ├── App.vue
│ │ ├── index.js
- the
index.js
file is our Vue.js application entry point - the file
App.vue
is its root component
App.vue
:
<template>
<div class="container">
<h1>Hello</h1>
</div>
</template>
<script>
export default {
name: "App"
};
</script>
index.js
:
import Vue from "vue";
import App from "./App";
new Vue({
components: { App },
template: "<App/>"
}).$mount("#app");
Right now we can't use those files directly as they can't be interpreted by our browser. Thanks to Webpack Encore, we are able to build a browser comprehensive output file with:
$ yarn dev
yarn watch
which will rebuild your frontend source code on every change.The resulting files are located at public/build
.
In the templates
folder, there is a file named base.html.twig
: this is where we'll be referencing the previous files:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Symfony 4 with a Vue.js SPA</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
{{ encore_entry_link_tags('app') }}
</head>
<body>
<div id="app"></div>
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script>
{{ encore_entry_script_tags('app') }}
</body>
</html>
<div id="app"></div>
is where our frontend application will be injected{{ encore_entry_script_tags('app') }}
is a Symfony shortcut to find our JavaScript built assets thanks to amanifest.json
file created by Webpack Encore. It reference for example a key (build/app.js
) with a file path: it's very useful because on your production environment you'll runyarn build
which adds a hash to your built filename (e.g.app.3290Ufd.js
). This prevent issues with cached Javascript files by your users' browsers{{ encore_entry_link_tags('app') }}
is a Symfony shortcut to find our CSS built assets. Currently, we don't have any style in our Vue.js.- we've also added some Bootstrap files via CDN for quick (and dirty?) styling
For rendering our file base.html.twig
, we just have to create a simple controller:
IndexController.php
:
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
final class IndexController extends AbstractController
{
/**
* @Route("/", name="index")
* @return Response
*/
public function indexAction(): Response
{
return $this->render('base.html.twig', []);
}
}
If you go to app.localhost, you'll see your Hello title displayed!
But what's going on?
- our request is interpreted by Traefik which redirects it to our PHP/Apache container
- Apache serves this request to the Symfony framework which matches it with the route defined in our controller
- the
indexAction
renders thebase.html.twig
and serves HTML to our browser - once our files Vue.js compiled code has been downloaded by our browser, our Vue.js application is bootstrapped and it injects the
App
component into the tag<div id="app"></div>
Quite simple, no? ☺
But this tutorial is about single-page application: our Vue.js application should handle the routing between our pages.
I'll explain how to do that in the next section.
Vue.js routing
Let's make our first page by creating the Home
component:
├── assets/
│ ├── vue/
│ │ ├── views/
│ │ │ ├── Home.vue
Home.vue
:
<template>
<div>
<div class="row col">
<h1>Homepage</h1>
</div>
<div class="row col">
<p>This is the homepage of our Vue.js application.</p>
</div>
</div>
</template>
<script>
export default {
name: "Home"
};
</script>
To make Vue.js render our Home
component, we also have to install vue-router
:
$ yarn add vue-router
And create our router:
├── assets/
│ ├── vue/
│ │ ├── router/
│ │ │ ├── index.js
index.js
:
import Vue from "vue";
import VueRouter from "vue-router";
import Home from "../views/Home";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{ path: "/home", component: Home },
{ path: "*", redirect: "/home" }
]
});
- with
mode: 'history'
, our URLs will look normal (e.g.http://app.localhost/home
) instead of the default mode which uses the URL hash to simulate a full URL to prevent page reloading on URL changes - we define a root path (e.g.
/home
) which uses ourHome
component - every URLs which are not listed in our routes will make the Vue.js application redirects the user to the homepage
Our router is ready, but we still have to reference it in our Vue.js application entry point:
index.js
:
import Vue from "vue";
import App from "./App";
import router from "./router";
new Vue({
components: { App },
template: "<App/>",
router
}).$mount("#app");
And update the App
component:
<template>
<div class="container">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<router-link
class="navbar-brand"
to="/home"
>
App
</router-link>
<button
class="navbar-toggler"
type="button"
data-toggle="collapse"
data-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon" />
</button>
<div
id="navbarNav"
class="collapse navbar-collapse"
>
<ul class="navbar-nav">
<router-link
class="nav-item"
tag="li"
to="/home"
active-class="active"
>
<a class="nav-link">Home</a>
</router-link>
</ul>
</div>
</nav>
<router-view />
</div>
</template>
<script>
export default {
name: "App",
}
</script>
<router-link>
is a Vue.js tag to create links to our pages. It accepts attributes liketag
which is the HTML tag it will be replaced by,to
for the link path andactive-class
which adds the given class if the current path matches with theto
attribute<router-view>
is the tag where will be injected your pages
Let's rebuild our source code with yarn dev
and go to app.localhost: you should automatically be redirected to app.localhost/home and see the homepage.
Unfortunately, we're not done: if you refresh the page, you will get a 404 Not Found error. ☹
This is because Symfony is still the main entry point of our application and it does not have a route /home
.
But don't worry, there is a simple solution: just update the indexAction
from the IndexController.php
:
/**
* @Route("/{vueRouting}", name="index")
* @return Response
*/
public function indexAction(): Response
{
return $this->render('base.html.twig', []);
}
As you can see, we've defined an optional route parameter vueRouting
which contains for example home
if you've requested the path /home
.
Voilà! ☺
As our routing is done, we'll see in the next section how to create API endpoints with Symfony to serve data from our database.
A REST API with Symfony
As explained in the introduction, our application should allow user to post messages.
So let's start by creating a Post
entity.
uuid
with the ramsey/uuid-doctrine library. Run composer require ramsey/uuid-doctrine
to install it!Post.php
:
<?php
declare(strict_types=1);
namespace App\Entity;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Exception;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
/**
* @ORM\Entity
* @ORM\Table(name="posts")
* @ORM\HasLifecycleCallbacks
*/
class Post
{
/**
* @ORM\Id
* @ORM\Column(type="uuid", unique=true)
*
* @var UuidInterface
*/
private $id;
/**
* @ORM\Column(name="message", type="string")
*
* @var string
*/
private $message;
/**
* @ORM\Column(name="created", type="datetime")
*
* @var DateTime
*/
private $created;
/**
* @ORM\Column(name="updated", type="datetime", nullable=true)
*
* @var DateTime|null
*/
private $updated;
/**
* @ORM\PrePersist
*
* @throws Exception;
*/
public function onPrePersist(): void
{
$this->id = Uuid::uuid4();
$this->created = new DateTime('NOW');
}
/**
* @ORM\PreUpdate
*/
public function onPreUpdate(): void
{
$this->updated = new DateTime('NOW');
}
public function getId(): UuidInterface
{
return $this->id;
}
public function getMessage(): string
{
return $this->message;
}
public function setMessage(string $message): void
{
$this->message = $message;
}
public function getCreated(): DateTime
{
return $this->created;
}
public function getUpdated(): ?DateTime
{
return $this->updated;
}
}
The next step is to create the posts
table in our database.
For this purpose, we install the DoctrineMigrations bundle:
$ composer require doctrine/doctrine-migrations-bundle "^2.0"
Good? Let's create a migration patch and execute it:
$ php bin/console doctrine:migrations:diff
$ php bin/console doctrine:migrations:migrate
Our database is now ready with its posts
table! Still, we have to create our API endpoints which will be used by our Vue.js application.
First, we have to install the FOSRest and Symfony Serializer bundles:
$ composer require friendsofsymfony/rest-bundle
Then we create a controller which handles the logic of our API:
PostController.php
:
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Post;
use Doctrine\ORM\EntityManagerInterface;
use FOS\RestBundle\Controller\Annotations as Rest;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\SerializerInterface;
/**
* @Rest\Route("/api")
*/
final class PostController extends AbstractController
{
/** @var EntityManagerInterface */
private $em;
/** @var SerializerInterface */
private $serializer;
public function __construct(EntityManagerInterface $em, SerializerInterface $serializer)
{
$this->em = $em;
$this->serializer = $serializer;
}
/**
* @throws BadRequestHttpException
*
* @Rest\Post("/posts", name="createPost")
*/
public function createAction(Request $request): JsonResponse
{
$message = $request->request->get('message');
if (empty($message)) {
throw new BadRequestHttpException('message cannot be empty');
}
$post = new Post();
$post->setMessage($message);
$this->em->persist($post);
$this->em->flush();
$data = $this->serializer->serialize($post, JsonEncoder::FORMAT);
return new JsonResponse($data, Response::HTTP_CREATED, [], true);
}
/**
* @Rest\Get("/posts", name="findAllPosts")
*/
public function findAllAction(): JsonResponse
{
$posts = $this->em->getRepository(Post::class)->findBy([], ['id' => 'DESC']);
$data = $this->serializer->serialize($posts, JsonEncoder::FORMAT);
return new JsonResponse($data, Response::HTTP_OK, [], true);
}
}
As you may have notice, our routes begin with /api
. We need to exclude those routes from the indexAction
of the IndexController.php
:
/**
* @Route("/{vueRouting}", requirements={"vueRouting"="^(?!api|_(profiler|wdt)).*"}, name="index")
* @return Response
*/
public function indexAction(): Response
{
return $this->render('base.html.twig', []);
}
By adding the attribute requirements={"vueRouting"="^(?!api|_(profiler|wdt)).*"}
to our route, it will not handle anymore routes beginning with /api
.
Please note that the Symfony debug bar is also excluded from this route.
Also, we're throwing a BadRequestHttpException
if there is no message. As we communicate with our frontend using the JSON format, we need to intercept such exceptions to transform them to JsonResponse
.
Let's begin by creating our exception listener:
<?php
declare(strict_types=1);
namespace App\Exception;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpException;
use function strpos;
final class HTTPExceptionListener
{
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getException();
if (! ($exception instanceof HttpException) || strpos($event->getRequest()->getRequestUri(), '/api/') === false) {
return;
}
$response = new JsonResponse(['error' => $exception->getMessage()]);
$response->setStatusCode($exception->getStatusCode());
$event->setResponse($response);
}
}
Here we check if the exception is an instance of HTTPException
and if we're calling an API endpoint.
Now we need to hook this listener; let's add it to the file config/service.yaml
:
App\Exception\HTTPExceptionListener:
tags:
- { name: kernel.event_listener, event: kernel.exception }
Our backend is now ready to serve data!
If you go to app.localhost/api/posts, you should see an empty JSON printed in the browser. ☺
In the next section, we'll see how to test our API endpoints with PHPUnit.
PHPUnit
Let's begin by updating our docker-compose.yml
file with a dedicated database for our tests:
mysql_tests:
image: mysql:5.7
environment:
MYSQL_ROOT_PASSWORD: "admin"
MYSQL_DATABASE: "tests"
MYSQL_USER: "foo"
MYSQL_PASSWORD: "bar"
volumes:
- ./services/mysql/utf8mb4.cnf:/etc/mysql/conf.d/utf8mb4.cnf:ro
We also need to reset the database every time we run our tests.
Best option is to use the DoctrineFixtures bundle:
$ composer require --dev doctrine/doctrine-fixtures-bundle
Once done, create the file bootstrap.php
at the root of your tests
folder:
<?php
declare(strict_types=1);
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
require dirname(__DIR__) . '/vendor/autoload.php';
$_SERVER += $_ENV;
$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null ?: 'dev';
$_SERVER['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? $_SERVER['APP_ENV'] !== 'prod';
$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = (int) $_SERVER['APP_DEBUG'] || filter_var($_SERVER['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN) ? '1' : '0';
$process = new Process(['php', 'bin/console', 'doctrine:migrations:migrate', '--no-interaction']);
$process->run();
if (! $process->isSuccessful()) {
throw new ProcessFailedException($process);
}
$process = new Process(['php', 'bin/console', 'doctrine:fixtures:load', '--no-interaction']);
$process->run();
if (! $process->isSuccessful()) {
throw new ProcessFailedException($process);
}
The content is quite close from the content of the file config/bootstrap.php
.
Only difference is that we also run the migrations and the command php bin/console doctrine:fixtures:load
which purges the database.
We may now update our phpunit.xml.dist
configuration file with:
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.de/manual/current/en/appendixes.configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/6.5/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="tests/bootstrap.php"
>
<php>
<ini name="error_reporting" value="-1" />
<env name="APP_ENV" value="test" force="true" />
<env name="SHELL_VERBOSITY" value="-1" />
<env name="SYMFONY_PHPUNIT_REMOVE" value="" />
<env name="SYMFONY_PHPUNIT_VERSION" value="6.5" />
<env name="KERNEL_CLASS" value="App\Kernel" />
<env name="DATABASE_URL" value="mysql://foo:bar@mysql_tests/tests" force="true" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory>src</directory>
</whitelist>
</filter>
<listeners>
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
</listeners>
</phpunit>
Here we have:
- updated the bootstrap file by replacing
config/bootstrap.php
withtests/bootstrap.php
- replaced the tags
<server />
with<env />
in thephp
tag - added the
DATABASE_URL
environment variable pointing to the database created before - added
KERNEL_CLASS
environment variable which is required
You may now stop your containers and re-up them:
$ docker-compose down
$ docker-compose up -d
$ docker-compose exec app bash
For integrating Symfony with PHPUnit, we have to install the PHPUnit Bridge component:
$ composer require --dev symfony/phpunit-bridge
$ php bin/phpunit
Good? Now let's create a Controller
folder inside the tests
folder. This is where we will store the tests related to our controllers.
For helping us writting test, we'll create a abstract class with useful methods:
AbstractControllerWebTestCase.php
:
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use Safe\Exceptions\JsonException;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
use function Safe\json_decode;
use function Safe\json_encode;
abstract class AbstractControllerWebTestCase extends WebTestCase
{
/** @var KernelBrowser */
protected $client;
protected function setUp(): void
{
self::bootKernel();
$this->client = static::createClient();
}
/**
* @param mixed[] $data
*
* @throws JsonException
*/
protected function JSONRequest(string $method, string $uri, array $data = []): void
{
$this->client->request($method, $uri, [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($data));
}
/**
* @return mixed
*
* @throws JsonException
*/
protected function assertJSONResponse(Response $response, int $expectedStatusCode)
{
$this->assertEquals($expectedStatusCode, $response->getStatusCode());
$this->assertTrue($response->headers->contains('Content-Type', 'application/json'));
$this->assertJson($response->getContent());
return json_decode($response->getContent(), true);
}
}
Then we create the test class PostControllerTest
where we'll test our PostController
:
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use Safe\Exceptions\JsonException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use function count;
final class PostControllerTest extends AbstractControllerWebTestCase
{
/**
* @throws JsonException
*/
public function testCreatePost(): void
{
// test that sending no message will result to a bad request HTTP code.
$this->JSONRequest(Request::METHOD_POST, '/api/posts');
$this->assertJSONResponse($this->client->getResponse(), Response::HTTP_BAD_REQUEST);
// test that sending a correct message will result to a created HTTP code.
$this->JSONRequest(Request::METHOD_POST, '/api/posts', ['message' => 'Hello world!']);
$this->assertJSONResponse($this->client->getResponse(), Response::HTTP_CREATED);
}
/**
* @throws JsonException
*/
public function testFindAllPosts(): void
{
$this->client->request(Request::METHOD_GET, '/api/posts');
$response = $this->client->getResponse();
$content = $this->assertJSONResponse($response, Response::HTTP_OK);
$this->assertEquals(1, count($content));
}
}
You may now run your tests suite with php bin/phpunit
!
In the next section, we'll see how to consume our API endpoints with Vue.js and how to store the retrieved data.
Consuming an API with Vue.js
For consuming our API endpoints, we'll use the axios library for our Ajax calls:
$ yarn add axios
Next we create our first consumer by creating the post.js
file:
├── assets/
│ ├── vue/
│ │ ├── api/
│ │ │ ├── post.js
post.js
:
import axios from "axios";
export default {
create(message) {
return axios.post("/api/posts", {
message: message
});
},
findAll() {
return axios.get("/api/posts");
}
};
Now you may wonder where will you call those methods and store the corresponding data.
We'll actually use a store
from the Vuex library.
$ yarn add vuex
Vuex is a state management pattern + library for Vue.js applications. It serves as a centralized store for all the components in an application, with rules ensuring that the state can only be mutated in a predictable fashion.
-- Vuex documentation
In others words, we'll have a main store which centralizes the data from our application.
This store will be accessible from all our components: if one component updates a value from the store, all components using this store will be updated too!
Also, a good practice is to split our main store into specialized modules. A module may be viewed as a department from a real store. Each module should be specialized and only manage data from its scope.
But enough theory; let's create a module for our posts:
├── assets/
│ ├── vue/
│ │ ├── store/
│ │ │ ├── post.js
post.js
:
import PostAPI from "../api/post";
const CREATING_POST = "CREATING_POST",
CREATING_POST_SUCCESS = "CREATING_POST_SUCCESS",
CREATING_POST_ERROR = "CREATING_POST_ERROR",
FETCHING_POSTS = "FETCHING_POSTS",
FETCHING_POSTS_SUCCESS = "FETCHING_POSTS_SUCCESS",
FETCHING_POSTS_ERROR = "FETCHING_POSTS_ERROR";
export default {
namespaced: true,
state: {
isLoading: false,
error: null,
posts: []
},
getters: {
isLoading(state) {
return state.isLoading;
},
hasError(state) {
return state.error !== null;
},
error(state) {
return state.error;
},
hasPosts(state) {
return state.posts.length > 0;
},
posts(state) {
return state.posts;
}
},
mutations: {
[CREATING_POST](state) {
state.isLoading = true;
state.error = null;
},
[CREATING_POST_SUCCESS](state, post) {
state.isLoading = false;
state.error = null;
state.posts.unshift(post);
},
[CREATING_POST_ERROR](state, error) {
state.isLoading = false;
state.error = error;
state.posts = [];
},
[FETCHING_POSTS](state) {
state.isLoading = true;
state.error = null;
state.posts = [];
},
[FETCHING_POSTS_SUCCESS](state, posts) {
state.isLoading = false;
state.error = null;
state.posts = posts;
},
[FETCHING_POSTS_ERROR](state, error) {
state.isLoading = false;
state.error = error;
state.posts = [];
}
},
actions: {
async create({ commit }, message) {
commit(CREATING_POST);
try {
let response = await PostAPI.create(message);
commit(CREATING_POST_SUCCESS, response.data);
return response.data;
} catch (error) {
commit(CREATING_POST_ERROR, error);
return null;
}
},
async findAll({ commit }) {
commit(FETCHING_POSTS);
try {
let response = await PostAPI.findAll();
commit(FETCHING_POSTS_SUCCESS, response.data);
return response.data;
} catch (error) {
commit(FETCHING_POSTS_ERROR, error);
return null;
}
}
}
};
Yes I know, a lot is going on here!
But it's actually quite simple:
- the attribute
namespaced: true
allows to reference directly this module when calling our main store (we'll create it just after) - the state contains our module data
- getters are simply methods which allow us to retrieve the data from the state
- mutations are named states of our data
- actions are methods which will mutate our module state
Good? We may now reference this module by creating the main store:
├── assets/
│ ├── vue/
│ ├── ├── store/
│ ├── ├── ├── index.js
│ ├── ├── ├── post.js
index.js
:
import Vue from "vue";
import Vuex from "vuex";
import PostModule from "./post";
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
post: PostModule
}
});
And reference this main store in our Vue.js application entry point:
index.js
:
import Vue from "vue";
import App from "./App";
import router from "./router";
import store from "./store";
new Vue({
components: { App },
template: "<App/>",
router,
store
}).$mount("#app");
Alright, our main store and its module are now available in our components.
So let's create a new page which will use them:
├── assets/
│ ├── vue/
│ │ ├── components/
│ │ │ ├── Post.vue
│ │ ├── views/
│ │ │ ├── Posts.vue
Posts.vue
:
<template>
<div>
<div class="row col">
<h1>Posts</h1>
</div>
<div class="row col">
<form>
<div class="form-row">
<div class="col-8">
<input
v-model="message"
type="text"
class="form-control"
>
</div>
<div class="col-4">
<button
:disabled="message.length === 0 || isLoading"
type="button"
class="btn btn-primary"
@click="createPost()"
>
Create
</button>
</div>
</div>
</form>
</div>
<div
v-if="isLoading"
class="row col"
>
<p>Loading...</p>
</div>
<div
v-else-if="hasError"
class="row col"
>
<div
class="alert alert-danger"
role="alert"
>
{{ error }}
</div>
</div>
<div
v-else-if="!hasPosts"
class="row col"
>
No posts!
</div>
<div
v-for="post in posts"
v-else
:key="post.id"
class="row col"
>
<post :message="post.message" />
</div>
</div>
</template>
<script>
import Post from "../components/Post";
export default {
name: "Posts",
components: {
Post
},
data() {
return {
message: ""
};
},
computed: {
isLoading() {
return this.$store.getters["post/isLoading"];
},
hasError() {
return this.$store.getters["post/hasError"];
},
error() {
return this.$store.getters["post/error"];
},
hasPosts() {
return this.$store.getters["post/hasPosts"];
},
posts() {
return this.$store.getters["post/posts"];
}
},
created() {
this.$store.dispatch("post/findAll");
},
methods: {
async createPost() {
const result = await this.$store.dispatch("post/create", this.$data.message);
if (result !== null) {
this.$data.message = "";
}
}
}
};
</script>
Post.vue
:
<template>
<div class="card w-100 mt-2">
<div class="card-body">
{{ message }}
</div>
</div>
</template>
<script>
export default {
name: "Post",
props: {
message: {
type: String,
required: true
}
}
};
</script>
- we may access to our store's modules getters with
this.$store.getters['moduleName/getterName']
- same for our store's modules actions with
this.$store.dispatch('moduleName/actionName', args...)
It's now time for the final touch: create a new route /posts
for the Posts
component:
index.js
:
import Vue from "vue";
import VueRouter from "vue-router";
import Home from "../views/Home";
import Posts from "../views/Posts";
Vue.use(VueRouter);
export default new VueRouter({
mode: "history",
routes: [
{ path: "/home", component: Home },
{ path: "/posts", component: Posts },
{ path: "*", redirect: "/home" }
]
});
App.vue
:
<template>
<div class="container">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<router-link
class="navbar-brand"
to="/home"
>
App
</router-link>
<button
class="navbar-toggler"
type="button"
data-toggle="collapse"
data-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon" />
</button>
<div
id="navbarNav"
class="collapse navbar-collapse"
>
<ul class="navbar-nav">
<router-link
class="nav-item"
tag="li"
to="/home"
active-class="active"
>
<a class="nav-link">Home</a>
</router-link>
<router-link
class="nav-item"
tag="li"
to="/posts"
active-class="active"
>
<a class="nav-link">Posts</a>
</router-link>
</ul>
</div>
</nav>
<router-view />
</div>
</template>
Rebuild your Vue.js source code (yarn dev
) and go to app.localhost/posts to see your developments in action! ☺
In the next section comes the most complicated part: I'll explain how to restrict the access to the posts to authenticated user.
Security
Symfony
We start by creating a User
entity:
User.php
:
<?php
declare(strict_types=1);
namespace App\Entity;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Exception;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity
* @ORM\Table(name="users")
* @ORM\HasLifecycleCallbacks
*/
class User implements UserInterface
{
/**
* @ORM\Id
* @ORM\Column(type="uuid", unique=true)
*
* @var UuidInterface
*/
private $id;
/**
* @ORM\Column(name="login", type="string", unique=true)
*
* @var string
* @Assert\NotBlank()
*/
private $login;
/**
* @var string|null
* @Assert\NotBlank()
* @Assert\Length(max=4096)
*/
private $plainPassword;
/**
* @ORM\Column(name="password", type="string")
*
* @var string|null
*/
private $password;
/**
* @ORM\Column(name="roles", type="simple_array")
*
* @var string[]
*/
private $roles;
/**
* @ORM\Column(name="created", type="datetime")
*
* @var DateTime
*/
private $created;
/**
* @ORM\Column(name="updated", type="datetime", nullable=true)
*
* @var DateTime
*/
private $updated;
public function __construct()
{
$this->roles = [];
}
/**
* @ORM\PrePersist
*
* @throws Exception
*/
public function onPrePersist(): void
{
$this->id = Uuid::uuid4();
$this->created = new DateTime('NOW');
}
/**
* @ORM\PreUpdate
*/
public function onPreUpdate(): void
{
$this->updated = new DateTime('NOW');
}
public function getId(): UuidInterface
{
return $this->id;
}
public function getLogin(): string
{
return $this->login;
}
public function setLogin(string $login): void
{
$this->login = $login;
}
public function getUsername(): string
{
return $this->login;
}
public function getPlainPassword(): ?string
{
return $this->plainPassword;
}
public function setPlainPassword(string $password): void
{
$this->plainPassword = $password;
// forces the object to look "dirty" to Doctrine. Avoids
// Doctrine *not* saving this entity, if only plainPassword changes
$this->password = null;
}
public function getPassword(): ?string
{
return $this->password;
}
public function setPassword(string $password): void
{
$this->password = $password;
}
/**
* @return null
*/
public function getSalt()
{
// The bcrypt algorithm doesn't require a separate salt.
return null;
}
/**
* @return string[]
*/
public function getRoles(): array
{
return $this->roles;
}
/**
* @param string[] $roles
*/
public function setRoles(array $roles): void
{
$this->roles = $roles;
}
public function eraseCredentials(): void
{
$this->plainPassword = null;
}
public function getCreated(): DateTime
{
return $this->created;
}
public function getUpdated(): ?DateTime
{
return $this->updated;
}
}
Like the Post
entity, we need to update our database with:
$ php bin/console doctrine:migrations:diff
$ php bin/console doctrine:migrations:migrate
Alright, now let's add an event listener to encode our users' password on persist:
HashPasswordListener.php
:
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\User;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use function get_class;
final class HashPasswordListener implements EventSubscriber
{
/** @var UserPasswordEncoderInterface */
private $passwordEncoder;
public function __construct(UserPasswordEncoderInterface $passwordEncoder)
{
$this->passwordEncoder = $passwordEncoder;
}
public function prePersist(LifecycleEventArgs $args): void
{
$entity = $args->getEntity();
if (! $entity instanceof User) {
return;
}
$this->encodePassword($entity);
}
public function preUpdate(LifecycleEventArgs $args): void
{
$entity = $args->getEntity();
if (! $entity instanceof User) {
return;
}
$this->encodePassword($entity);
// necessary to force the update to see the change
$em = $args->getEntityManager();
$meta = $em->getClassMetadata(get_class($entity));
$em->getUnitOfWork()->recomputeSingleEntityChangeSet($meta, $entity);
}
/**
* {@inheritdoc}
*/
public function getSubscribedEvents()
{
return ['prePersist', 'preUpdate'];
}
private function encodePassword(User $entity): void
{
$plainPassword = $entity->getPlainPassword();
if ($plainPassword === null) {
return;
}
$encoded = $this->passwordEncoder->encodePassword(
$entity,
$plainPassword
);
$entity->setPassword($encoded);
}
}
And register it in the file located at config/services.yaml
:
app.security.hash.password.listener:
class: App\Security\HashPasswordListener
tags:
- { name: doctrine.event_subscriber }
Next step is to configure Symfony by telling it to use our User entity for JSON authentication.
Replace the content of the file located at config/packages/security.yaml
with:
security:
# https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
encoders:
App\Entity\User:
algorithm: auto
providers:
in_memory: { memory: ~ }
pdo:
entity:
class: App\Entity\User
property: login
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
anonymous: true
provider: pdo
json_login:
check_path: /api/security/login
logout:
path: /api/security/logout
# activate different ways to authenticate
# http_basic: true
# https://symfony.com/doc/current/security.html#a-configuring-how-your-users-will-authenticate
# form_login: true
# https://symfony.com/doc/current/security/form_login_setup.html
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
- we added an encoder for the password of our users (auto)
- we told Symfony that our resource for authentication is the
User
entity - we provided a
json_login
method with the login route/api/security/login
- we provided a logout route
/api/security/logout
Now we need to tell Symfony how to store the sessions which contain our authenticated users.
The best option currently is to store the sessions in a remote location - MySQL for instance.
You could also store the sessions using files, but your application will not be stateless anymore. This may cause you some troubles if you want to scale your application in production.
Let's begin by replacing the content of the file located at config/packages/frameworks.yaml
:
framework:
secret: '%env(APP_SECRET)%'
#default_locale: en
#csrf_protection: true
#http_method_override: true
# Enables session support. Note that the session will ONLY be started if you read or write from it.
# Remove or comment this section to explicitly disable session support.
session:
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler
cookie_secure: auto
cookie_samesite: lax
#esi: true
#fragments: true
php_errors:
log: true
Then update the file located at config/services.yaml
with:
Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
arguments:
- !service { class: PDO, factory: 'database_connection:getWrappedConnection' }
# If you get transaction issues (e.g. after login) uncomment the line below
- { lock_mode: 1 }
We also need to create the sessions
table:
$ php bin/console doctrine:migrations:generate
Once your new migration is created, update it with the following content:
public function up(Schema $schema) : void
{
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('CREATE TABLE sessions (sess_id VARCHAR(128) NOT NULL PRIMARY KEY, sess_data BLOB NOT NULL, sess_time INTEGER UNSIGNED NOT NULL, sess_lifetime MEDIUMINT NOT NULL) COLLATE utf8_bin, ENGINE = InnoDB');
}
public function down(Schema $schema) : void
{
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('DROP TABLE sessions');
}
Apply the migration:
$ php bin/console doctrine:migrations:migrate
Let's not forget update the file located at config/packages/doctrine.yaml
with:
doctrine:
dbal:
# ...
schema_filter: ~^(?!sessions)~
This will prevent Doctrine to add the DROP TABLE sessions
when running php bin/console doctrine:migrations:diff
(as there is no Session
entity).
Good? We may now continue with our security API endpoints:
SecurityController.php
:
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\SerializerInterface;
/**
* @Route("/api")
*/
final class SecurityController extends AbstractController
{
/** @var SerializerInterface */
private $serializer;
public function __construct(SerializerInterface $serializer)
{
$this->serializer = $serializer;
}
/**
* @Route("/security/login", name="login")
*/
public function loginAction(): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$userClone = clone $user;
$userClone->setPassword('');
$data = $this->serializer->serialize($userClone, JsonEncoder::FORMAT);
return new JsonResponse($data, Response::HTTP_OK, [], true);
}
/**
* @throws RuntimeException
*
* @Route("/security/logout", name="logout")
*/
public function logoutAction(): void
{
throw new RuntimeException('This should not be reached!');
}
}
$user
to be able to set a blank password. Otherwise updating directly the $user
will result to an invalid session.Let's not forget to add a @IsGranted("IS_AUTHENTICATED_FULLY")
to our PostController
class to prevent non-authenticated users to manipulate our posts:
PostController.php
:
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
/**
* @Rest\Route("/api")
* @IsGranted("IS_AUTHENTICATED_FULLY")
*/
final class PostController extends AbstractController
Also, we restrict the post creation to users who have the role ROLE_FOO
:
PostController.php
:
/**
* @Rest\Post("/api/posts", name="createPost")
* @IsGranted("ROLE_FOO")
*/
public function createAction(Request $request): JsonResponse
Last but not least, we want to create a default user for our tests:
UserFixtures.php
:
<?php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
final class UserFixtures extends Fixture
{
public const DEFAULT_USER_LOGIN = 'login';
public const DEFAULT_USER_PASSWORD = 'bar';
public function load(ObjectManager $manager): void
{
$userEntity = new User();
$userEntity->setLogin(self::DEFAULT_USER_LOGIN);
$userEntity->setPlainPassword(self::DEFAULT_USER_PASSWORD);
$userEntity->setRoles(['ROLE_FOO']);
$manager->persist($userEntity);
$manager->flush();
}
}
And run php bin/console doctrine:fixtures:load
to load it into the database! ☺
PHPUnit
Right now, our tests suite for the PostController
does not work anymore: indeed, we need to be authenticated in order to use those API endpoints.
Let's begin to add a new user in our UserFixtures
as we want to test among other things if a user without the role ROLE_FOO
is indeed not able to create a post:
<?php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
final class UserFixtures extends Fixture
{
public const DEFAULT_USER_LOGIN = 'foo';
public const DEFAULT_USER_PASSWORD = 'bar';
public const USER_LOGIN_ROLE_BAR = 'bar';
public const USER_PASSWORD_ROLE_BAR = 'foo';
public function load(ObjectManager $manager): void
{
$this->createUser($manager, self::DEFAULT_USER_LOGIN, self::DEFAULT_USER_PASSWORD, ['ROLE_FOO']);
$this->createUser($manager, self::USER_LOGIN_ROLE_BAR, self::USER_PASSWORD_ROLE_BAR, ['ROLE_BAR']);
}
/**
* @param string[] $roles
*/
private function createUser(ObjectManager $manager, string $login, string $password, array $roles): void
{
$userEntity = new User();
$userEntity->setLogin($login);
$userEntity->setPlainPassword($password);
$userEntity->setRoles($roles);
$manager->persist($userEntity);
$manager->flush();
}
}
We may now update our helper class AbstractControllerWebTestCase
by adding a new method for simulating a login:
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use App\DataFixtures\UserFixtures;
use Safe\Exceptions\JsonException;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use function Safe\json_decode;
use function Safe\json_encode;
abstract class AbstractControllerWebTestCase extends WebTestCase
{
/** @var KernelBrowser */
protected $client;
protected function setUp(): void
{
self::bootKernel();
$this->client = static::createClient();
}
/**
* @param mixed[] $data
*
* @throws JsonException
*/
protected function JSONRequest(string $method, string $uri, array $data = []): void
{
$this->client->request($method, $uri, [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($data));
}
/**
* @return mixed
*
* @throws JsonException
*/
protected function assertJSONResponse(Response $response, int $expectedStatusCode)
{
$this->assertEquals($expectedStatusCode, $response->getStatusCode());
$this->assertTrue($response->headers->contains('Content-Type', 'application/json'));
$this->assertJson($response->getContent());
return json_decode($response->getContent(), true);
}
/**
* @throws JsonException
*/
protected function login(string $username = UserFixtures::DEFAULT_USER_LOGIN, string $password = UserFixtures::DEFAULT_USER_PASSWORD): void
{
$this->client->request(Request::METHOD_POST, '/api/security/login', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['username' => $username, 'password' => $password]));
$this->assertEquals(Response::HTTP_OK, $this->client->getResponse()->getStatusCode());
}
}
Last but not least, let's update our PostControllerTest
tests suite:
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use App\DataFixtures\UserFixtures;
use Safe\Exceptions\JsonException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use function count;
final class PostControllerTest extends AbstractControllerWebTestCase
{
/**
* @throws JsonException
*/
public function testCreatePost(): void
{
// test that sending a request without being authenticated will result to a unauthorized HTTP code.
$this->JSONRequest(Request::METHOD_POST, '/api/posts');
$this->assertJSONResponse($this->client->getResponse(), Response::HTTP_UNAUTHORIZED);
// test that sending a request while not having the role "ROLE_FOO" will result to a forbidden HTTP code.
$this->login(UserFixtures::USER_LOGIN_ROLE_BAR, UserFixtures::USER_PASSWORD_ROLE_BAR);
$this->JSONRequest(Request::METHOD_POST, '/api/posts');
$this->assertJSONResponse($this->client->getResponse(), Response::HTTP_FORBIDDEN);
// test that sending a request while begin authenticated will result to a created HTTP code.
$this->login();
$this->JSONRequest(Request::METHOD_POST, '/api/posts', ['message' => 'Hello world!']);
$this->assertJSONResponse($this->client->getResponse(), Response::HTTP_CREATED);
// test that sending no message will result to a bad request HTTP code.
$this->JSONRequest(Request::METHOD_POST, '/api/posts');
$this->assertJSONResponse($this->client->getResponse(), Response::HTTP_BAD_REQUEST);
}
/**
* @throws JsonException
*/
public function testFindAllPosts(): void
{
// test that sending a request without being authenticated will result to a unauthorized HTTP code.
$this->client->request(Request::METHOD_GET, '/api/posts');
$this->assertJSONResponse($this->client->getResponse(), Response::HTTP_UNAUTHORIZED);
// test that sending a request while begin authenticated will result to a OK HTTP code.
$this->login();
$this->client->request(Request::METHOD_GET, '/api/posts');
$response = $this->client->getResponse();
$content = $this->assertJSONResponse($response, Response::HTTP_OK);
$this->assertEquals(1, count($content));
}
}
You may now run your tests suite with php bin/phpunit
!
Pheww... Our backend is now ready to handle JSON authentication! ☺
But we're not done yet: we need to update our Vue.js application to prevent the user from accessing to the posts if he's not authenticated.
I'll show you how to do that in the next section!
Vue.js
Let's list the cases we have to handle:
- if the user tries to access the page
/posts
without being authenticated, we should redirect him to the login page - if the user is authenticated and tries to access the
/login
page, we should redirect him to the home page - if an Ajax call returns a 401 HTTP code, we should redirect the user to the login page
- if the user refreshes the page
/posts
and he's already authenticated, we should not redirect him to the login page
We begin by creating our security API consumer:
├── assets/
│ ├── vue/
│ │ ├── api/
│ │ │ ├── security.js
security.js
:
import axios from "axios";
export default {
login(login, password) {
return axios.post("/api/security/login", {
username: login,
password: password
});
}
}
And the module of our main store which will... store the data related to security:
├── assets/
│ ├── vue/
│ │ ├── store/
│ │ │ ├── security.js
security.js
:
import SecurityAPI from "../api/security";
const AUTHENTICATING = "AUTHENTICATING",
AUTHENTICATING_SUCCESS = "AUTHENTICATING_SUCCESS",
AUTHENTICATING_ERROR = "AUTHENTICATING_ERROR";
export default {
namespaced: true,
state: {
isLoading: false,
error: null,
isAuthenticated: false,
user: null
},
getters: {
isLoading(state) {
return state.isLoading;
},
hasError(state) {
return state.error !== null;
},
error(state) {
return state.error;
},
isAuthenticated(state) {
return state.isAuthenticated;
},
hasRole(state) {
return role => {
return state.user.roles.indexOf(role) !== -1;
}
}
},
mutations: {
[AUTHENTICATING](state) {
state.isLoading = true;
state.error = null;
state.isAuthenticated = false;
state.user = null;
},
[AUTHENTICATING_SUCCESS](state, user) {
state.isLoading = false;
state.error = null;
state.isAuthenticated = true;
state.user = user;
},
[AUTHENTICATING_ERROR](state, error) {
state.isLoading = false;
state.error = error;
state.isAuthenticated = false;
state.user = null;
}
},
actions: {
async login({commit}, payload) {
commit(AUTHENTICATING);
try {
let response = await SecurityAPI.login(payload.login, payload.password);
commit(AUTHENTICATING_SUCCESS, response.data);
return response.data;
} catch (error) {
commit(AUTHENTICATING_ERROR, error);
return null;
}
}
}
}
Add this module to our main store:
index.js
:
import Vue from "vue";
import Vuex from "vuex";
import SecurityModule from "./security";
import PostModule from "./post";
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
security: SecurityModule,
post: PostModule
}
});
We may now use this module in a new Login
component:
├── assets/
│ ├── vue/
│ │ ├── views/
│ │ │ ├── Login.vue
Login.vue
:
<template>
<div>
<div class="row col">
<h1>Login</h1>
</div>
<div class="row col">
<form>
<div class="form-row">
<div class="col-4">
<input
v-model="login"
type="text"
class="form-control"
>
</div>
<div class="col-4">
<input
v-model="password"
type="password"
class="form-control"
>
</div>
<div class="col-4">
<button
:disabled="login.length === 0 || password.length === 0 || isLoading"
type="button"
class="btn btn-primary"
@click="performLogin()"
>
Login
</button>
</div>
</div>
</form>
</div>
<div
v-if="isLoading"
class="row col"
>
<p>Loading...</p>
</div>
<div
v-else-if="hasError"
class="row col"
>
<div
class="alert alert-danger"
role="alert"
>
{{ error }}
</div>
</div>
</div>
</template>
<script>
export default {
name: "Login",
data() {
return {
login: "",
password: ""
};
},
computed: {
isLoading() {
return this.$store.getters["security/isLoading"];
},
hasError() {
return this.$store.getters["security/hasError"];
},
error() {
return this.$store.getters["security/error"];
}
},
created() {
let redirect = this.$route.query.redirect;
if (this.$store.getters["security/isAuthenticated"]) {
if (typeof redirect !== "undefined") {
this.$router.push({path: redirect});
} else {
this.$router.push({path: "/home"});
}
}
},
methods: {
async performLogin() {
let payload = {login: this.$data.login, password: this.$data.password},
redirect = this.$route.query.redirect;
await this.$store.dispatch("security/login", payload);
if (!this.$store.getters["security/hasError"]) {
if (typeof redirect !== "undefined") {
this.$router.push({path: redirect});
} else {
this.$router.push({path: "/home"});
}
}
}
}
}
</script>
Let's not forget to update our router:
index.js
:
import Vue from "vue";
import VueRouter from "vue-router";
import store from "../store";
import Home from "../views/Home";
import Login from "../views/Login";
import Posts from "../views/Posts";
Vue.use(VueRouter);
let router = new VueRouter({
mode: "history",
routes: [
{ path: "/home", component: Home },
{ path: "/login", component: Login },
{ path: "/posts", component: Posts, meta: { requiresAuth: true } },
{ path: "*", redirect: "/home" }
],
});
router.beforeEach((to, from, next) => {
if (to.matched.some(record => record.meta.requiresAuth)) {
// this route requires auth, check if logged in
// if not, redirect to login page.
if (store.getters["security/isAuthenticated"]) {
next();
} else {
next({
path: "/login",
query: { redirect: to.fullPath }
});
}
} else {
next(); // make sure to always call next()!
}
});
export default router;
So what's going on?
- we added a new meta (
requiresAuth: true
) to our/posts
path - before each route change, we check if this meta is available for the wanted route (which means the user has to be authenticated to access it)
- if so, we call the getter
isAuthenticated
from oursecurity
module - if not authenticated, we redirect the user to the login page (with the optional
redirect
query parameter) On authentication success in ourLogin
component, we check if this query parameter is available to redirect the user to its wanted route, otherwise we redirect him to the homepage
Simpler, the logout:
App.vue
:
<template>
<div class="container">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<router-link
class="navbar-brand"
to="/home"
>
App
</router-link>
<button
class="navbar-toggler"
type="button"
data-toggle="collapse"
data-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon" />
</button>
<div
id="navbarNav"
class="collapse navbar-collapse"
>
<ul class="navbar-nav">
<router-link
class="nav-item"
tag="li"
to="/home"
active-class="active"
>
<a class="nav-link">Home</a>
</router-link>
<router-link
class="nav-item"
tag="li"
to="/posts"
active-class="active"
>
<a class="nav-link">Posts</a>
</router-link>
<li
v-if="isAuthenticated"
class="nav-item"
>
<a
class="nav-link"
href="/api/security/logout"
>Logout</a>
</li>
</ul>
</div>
</nav>
<router-view />
</div>
</template>
<script>
export default {
name: 'App',
computed: {
isAuthenticated() {
return this.$store.getters['security/isAuthenticated']
},
},
}
</script>
- we added a link to
/api/security/logout
: Symfony will automatically redirect the user to the root path - this link is conditionally displayed thanks to the getter
isAuthenticated
from oursecurity
module
Now let's handle the third case: if an Ajax call returns a 401 HTTP code, we should redirect the user to the login page.
We have to update our App.vue
:
<script>
import axios from "axios";
export default {
name: "App",
computed: {
isAuthenticated() {
return this.$store.getters["security/isAuthenticated"]
},
},
created() {
axios.interceptors.response.use(undefined, (err) => {
return new Promise(() => {
if (err.response.status === 401) {
this.$router.push({path: "/login"})
}
throw err;
});
});
},
}
</script>
- we added the
created
attribute where we're telling axios that for any Ajax calls which returns a 401 HTTP code, we redirect the user to thelogin
page
Last case: if the user refreshes the page /posts
and he's already authenticated, we should not redirect him to the login page.
We begin by updating our IndexController.php
:
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use Safe\Exceptions\JsonException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\SerializerInterface;
use function Safe\json_encode;
final class IndexController extends AbstractController
{
/** @var SerializerInterface */
private $serializer;
public function __construct(SerializerInterface $serializer)
{
$this->serializer = $serializer;
}
/**
* @throws JsonException
*
* @Route("/{vueRouting}", requirements={"vueRouting"="^(?!api|_(profiler|wdt)).*"}, name="index")
*/
public function indexAction(): Response
{
/** @var User|null $user */
$user = $this->getUser();
$data = null;
if (! empty($user)) {
$userClone = clone $user;
$userClone->setPassword('');
$data = $this->serializer->serialize($userClone, JsonEncoder::FORMAT);
}
return $this->render('base.html.twig', [
'isAuthenticated' => json_encode(! empty($user)),
'user' => $data ?? json_encode($data),
]);
}
}
Then our base.html.twig
:
<div id="app" data-is-authenticated="{{ isAuthenticated }}" data-user="{{ user }}"></div>
Then our App.vue
:
<script>
import axios from "axios";
export default {
name: "App",
computed: {
isAuthenticated() {
return this.$store.getters["security/isAuthenticated"]
},
},
created() {
let isAuthenticated = JSON.parse(this.$parent.$el.attributes["data-is-authenticated"].value),
user = JSON.parse(this.$parent.$el.attributes["data-user"].value);
let payload = { isAuthenticated: isAuthenticated, user: user };
this.$store.dispatch("security/onRefresh", payload);
axios.interceptors.response.use(undefined, (err) => {
return new Promise(() => {
if (err.response.status === 401) {
this.$router.push({path: "/login"})
}
throw err;
});
});
},
}
</script>
And finally our store module security.js
:
import SecurityAPI from "../api/security";
const AUTHENTICATING = "AUTHENTICATING",
AUTHENTICATING_SUCCESS = "AUTHENTICATING_SUCCESS",
AUTHENTICATING_ERROR = "AUTHENTICATING_ERROR",
PROVIDING_DATA_ON_REFRESH_SUCCESS = "PROVIDING_DATA_ON_REFRESH_SUCCESS";
export default {
namespaced: true,
state: {
isLoading: false,
error: null,
isAuthenticated: false,
user: null
},
getters: {
isLoading(state) {
return state.isLoading;
},
hasError(state) {
return state.error !== null;
},
error(state) {
return state.error;
},
isAuthenticated(state) {
return state.isAuthenticated;
},
hasRole(state) {
return role => {
return state.user.roles.indexOf(role) !== -1;
}
}
},
mutations: {
[AUTHENTICATING](state) {
state.isLoading = true;
state.error = null;
state.isAuthenticated = false;
state.user = null;
},
[AUTHENTICATING_SUCCESS](state, user) {
state.isLoading = false;
state.error = null;
state.isAuthenticated = true;
state.user = user;
},
[AUTHENTICATING_ERROR](state, error) {
state.isLoading = false;
state.error = error;
state.isAuthenticated = false;
state.user = null;
},
[PROVIDING_DATA_ON_REFRESH_SUCCESS](state, payload) {
state.isLoading = false;
state.error = null;
state.isAuthenticated = payload.isAuthenticated;
state.user = payload.user;
}
},
actions: {
async login({commit}, payload) {
commit(AUTHENTICATING);
try {
let response = await SecurityAPI.login(payload.login, payload.password);
commit(AUTHENTICATING_SUCCESS, response.data);
return response.data;
} catch (error) {
commit(AUTHENTICATING_ERROR, error);
return null;
}
},
onRefresh({commit}, payload) {
commit(PROVIDING_DATA_ON_REFRESH_SUCCESS, payload);
}
}
}
So what's going on?
In the indexAction
we check if a user is authenticated. If so, we also serializes it to JSON.
We give those data to our Vue.js application thanks to the attributes data-is-authenticated
and data-user
from the file base.html.twig
.
Our App component will then use those data to automatically set the state of our store module security
thanks to the mutation PROVIDING_DATA_ON_REFRESH_SUCCESS
.
Final touch: we have to show the post creation form only to users who have the role ROLE_FOO
:
Posts.vue
:
<template>
<div>
<div class="row col">
<h1>Posts</h1>
</div>
<div
v-if="canCreatePost"
class="row col"
>
<form>
<div class="form-row">
<div class="col-8">
<input
v-model="message"
type="text"
class="form-control"
>
</div>
<div class="col-4">
<button
:disabled="message.length === 0 || isLoading"
type="button"
class="btn btn-primary"
@click="createPost()"
>
Create
</button>
</div>
</div>
</form>
</div>
<div
v-if="isLoading"
class="row col"
>
<p>Loading...</p>
</div>
<div
v-else-if="hasError"
class="row col"
>
<div
class="alert alert-danger"
role="alert"
>
{{ error }}
</div>
</div>
<div
v-else-if="!hasPosts"
class="row col"
>
No posts!
</div>
<div
v-for="post in posts"
v-else
:key="post.id"
class="row col"
>
<post :message="post.message" />
</div>
</div>
</template>
<script>
import Post from "../components/Post";
export default {
name: "Posts",
components: {
Post
},
data() {
return {
message: ""
};
},
computed: {
isLoading() {
return this.$store.getters["post/isLoading"];
},
hasError() {
return this.$store.getters["post/hasError"];
},
error() {
return this.$store.getters["post/error"];
},
hasPosts() {
return this.$store.getters["post/hasPosts"];
},
posts() {
return this.$store.getters["post/posts"];
},
canCreatePost() {
return this.$store.getters["security/hasRole"]("ROLE_FOO");
}
},
created() {
this.$store.dispatch("post/findAll");
},
methods: {
async createPost() {
const result = await this.$store.dispatch("post/create", this.$data.message);
if (result !== null) {
this.$data.message = "";
}
}
}
};
</script>
- we added the function
canCreatePost
which checks if the current user has the roleROLE_FOO
So what's next? Currently, when an exception occurs in the backend, we directly display a weird JSON to the user.
But that's not a good practice: let's see how to handle backend exceptions correctly in the next section!
Handling backend exceptions
There are actually two kinds of exceptions:
- the ones occurring due to a coding error: they should fail loud and display an error page
- the ones occurring due to a user error: they should inform the user about what's wrong and be displayed nicely in our Vue.js application
The first kind mostly returns a 500 Internal Server Error. So let's update our App.vue
to handle those exceptions:
<script>
import axios from "axios";
export default {
name: "App",
computed: {
isAuthenticated() {
return this.$store.getters["security/isAuthenticated"]
},
},
created() {
let isAuthenticated = JSON.parse(this.$parent.$el.attributes["data-is-authenticated"].value),
user = JSON.parse(this.$parent.$el.attributes["data-user"].value);
let payload = { isAuthenticated: isAuthenticated, user: user };
this.$store.dispatch("security/onRefresh", payload);
axios.interceptors.response.use(undefined, (err) => {
return new Promise(() => {
if (err.response.status === 401) {
this.$router.push({path: "/login"})
} else if (err.response.status === 500) {
document.open();
document.write(err.response.data);
document.close();
}
throw err;
});
});
},
}
</script>
When a 500 status code is intercepted by axios, we simply replace our HTML with the error page. If development, Symfony will indeed display a nice stack trace which is useful for us, developers. If production, a basic error page will be shown.
Next, let's create a component to display the second kind of exceptions:
├── assets/
│ ├── vue/
│ │ ├── components/
│ │ │ ├── ErrorMessage.vue
ErrorMessage.vue
:
<template>
<div
class="alert alert-danger"
role="alert"
>
{{ error.response.data.error }}
</div>
</template>
<script>
export default {
name: "ErrorMessage",
props: {
error: {
type: Error,
required: true
}
},
}
</script>
And updates our Posts.vue
and Login.vue
components in order to use this new component:
Posts.vue
:
<template>
<div>
<div class="row col">
<h1>Posts</h1>
</div>
<div
v-if="canCreatePost"
class="row col"
>
<form>
<div class="form-row">
<div class="col-8">
<input
v-model="message"
type="text"
class="form-control"
>
</div>
<div class="col-4">
<button
:disabled="message.length === 0 || isLoading"
type="button"
class="btn btn-primary"
@click="createPost()"
>
Create
</button>
</div>
</div>
</form>
</div>
<div
v-if="isLoading"
class="row col"
>
<p>Loading...</p>
</div>
<div
v-else-if="hasError"
class="row col"
>
<error-message :error="error" />
</div>
<div
v-else-if="!hasPosts"
class="row col"
>
No posts!
</div>
<div
v-for="post in posts"
v-else
:key="post.id"
class="row col"
>
<post :message="post.message" />
</div>
</div>
</template>
<script>
import Post from "../components/Post";
import ErrorMessage from "../components/ErrorMessage";
export default {
name: "Posts",
components: {
Post,
ErrorMessage
},
data() {
return {
message: ""
};
},
computed: {
isLoading() {
return this.$store.getters["post/isLoading"];
},
hasError() {
return this.$store.getters["post/hasError"];
},
error() {
return this.$store.getters["post/error"];
},
hasPosts() {
return this.$store.getters["post/hasPosts"];
},
posts() {
return this.$store.getters["post/posts"];
},
canCreatePost() {
return this.$store.getters["security/hasRole"]("ROLE_FOO");
}
},
created() {
this.$store.dispatch("post/posts");
},
methods: {
async createPost() {
const result = await this.$store.dispatch("post/create", this.$data.message);
if (result !== null) {
this.$data.message = "";
}
}
}
};
</script>
Login.vue
:
<template>
<div>
<div class="row col">
<h1>Login</h1>
</div>
<div class="row col">
<form>
<div class="form-row">
<div class="col-4">
<input
v-model="login"
type="text"
class="form-control"
>
</div>
<div class="col-4">
<input
v-model="password"
type="password"
class="form-control"
>
</div>
<div class="col-4">
<button
:disabled="login.length === 0 || password.length === 0 || isLoading"
type="button"
class="btn btn-primary"
@click="performLogin()"
>
Login
</button>
</div>
</div>
</form>
</div>
<div
v-if="isLoading"
class="row col"
>
<p>Loading...</p>
</div>
<div
v-else-if="hasError"
class="row col"
>
<error-message :error="error" />
</div>
</div>
</template>
<script>
import ErrorMessage from "../components/ErrorMessage";
export default {
name: "Login",
components: {
ErrorMessage,
},
data() {
return {
login: "",
password: ""
};
},
computed: {
isLoading() {
return this.$store.getters["security/isLoading"];
},
hasError() {
return this.$store.getters["security/hasError"];
},
error() {
return this.$store.getters["security/error"];
}
},
created() {
let redirect = this.$route.query.redirect;
if (this.$store.getters["security/isAuthenticated"]) {
if (typeof redirect !== "undefined") {
this.$router.push({path: redirect});
} else {
this.$router.push({path: "/home"});
}
}
},
methods: {
async performLogin() {
let payload = {login: this.$data.login, password: this.$data.password},
redirect = this.$route.query.redirect;
await this.$store.dispatch("security/login", payload);
if (!this.$store.getters["security/hasError"]) {
if (typeof redirect !== "undefined") {
this.$router.push({path: redirect});
} else {
this.$router.push({path: "/home"});
}
}
}
}
}
</script>
Aaaand we're done! At least for this tutorial. There is a room for some improvements, as details below.
Improvements
Docker Compose
The image thecodingmachine/php:7.3-v2-apache-node10
allows us to launch commands on container startup using the STARTUP_COMMAND_XXX
environment variables.
In our case, we can do something like:
app:
image: thecodingmachine/php:7.3-v2-apache-node10
# ...
environment:
# ...
STARTUP_COMMAND_1: composer install && bin/console doctrine:migrations:migrate --no-interaction
STARTUP_COMMAND_2: yarn install && yarn watch &
# ...
If one of your co-worker installs the project, he'll just have to run docker-compose up -d
and his environment will automatically be ready!
CSRF
See https://symfony.com/doc/current/security/csrf.html
Alternative
You could put your Symfony API and Vue.js application in two containers.
Respectively, a PHP container and a Node.js container.
It means that Symfony will not be your main entry point anymore, but it actually will be the role of Node.js to serve your application.
Be careful so, you'll encounter some CORS issues!
Conclusion
I hope this tutorial will help you building nice SPA with Symfony and Vue.js.
If you have any issues, please fill them on the Github repository.
Have fun! ☺