Inspired by a great and comprehensive article over at thecodingmachine.io we’ll try something similar today: building a single page application using the Slim Framework and Vue.js.
In this tutorial we’ll see how to:
- set up the development environment with Docker
- build the application using the Slim Framework
- use Vue.js and the Vue router to build a SPA
Slim Framework
For work projects I usually work with Symfony. Symfony is a feature packed framework offering solutions for just about any problem. Which is great most of the time, but can sometimes feel a bit heavy and tedious to work with. That’s why I like to look at the other end of the spectrum for private projects. Projects like the Slim Framework.
The Slim Framework offers the bare basics to build a web application, which is receiving HTTP requests, invoking a callback function and returning HTTP responses. That’s it and that’s why it is super small and fast.
Vue.js
Modern JavaScript has come a long way since the era of jQuery (which is still used on almost 80% of all websites according to this survey). The frontend will be a vue.js SPA. This means no more page loads. Instead we have lots of loader icons 😉
Development Environment
The source code is available at: https://github.com/TPete/spa-slim-framework-vuejs
Docker setup
We start with a pretty basic docker setup:
- apache
- php-fpm
docker-compose.yml
version: '3' services: apache: container_name: spa_apache build: .docker/apache ports: - 80:80 volumes: - ./:/var/www/html - .docker/config/vhosts:/etc/apache2/sites-enabled - .docker/data/logs/apache:/var/log/apache2:delegated depends_on: - php-fpm networks: - internal php-fpm: container_name: spa_php build: context: .docker/php-fpm volumes: - ./:/var/www/html:delegated - .docker/data/logs/php:/var/log/php:delegated expose: - 9000 networks: - internal networks: internal:
Yep, no database! We’ll just store the example data as a JSON file.
Note: the docker files for both containers as well as the necessary configuration (vhost, php.ini) are in the repository.
To test the setup let’s add an info.php
:
<?php phpinfo();
Start the containers using docker-compose up -d
. After that calling localhost/info.php
shows the phpinfo()
output.
Composer
We’ll work with a local composer.phar
, so head over to https://getcomposer.org/download/ and follow the command-line installation process. This requires PHP to be installed. So let’s just do this from within the PHP container.
docker exec -it spa_php bash
Note: on windows you need to prefix this with winpty
.
The document root is at /var/www/html
.
Slim Framework
The recommend way to start a new project using the Slim Framework is to use their skeleton application. But for this tutorial we don’t really need most of the things from it. We just start with a basic composer.json
:
{ "name": "tutorial/spa", "description": "A single-page application (SPA) with Slim Framework and Vue.js", "type": "project", "autoload": { "psr-4": { "SPA\\": "src" } }, "require": {} }
To install the framework use:
php composer.phar require slim/slim:"^4.0"
The Slim Framework also requires a PSR-7 implementation. They recommend to use one of these. We just use their own implementation:
php composer.phar require slim/psr7
An optional, but recommend step is to add a PSR-11 compliant dependency injection container. We use PHP-DI:
php composer.phar require php-di/slim-bridge
Using the PHP-DI slim bridge offers several advantages, like using autowiring or flexible controller arguments. Read more here.
The last step is to add Slim’s PHP view component. This is used to render PHP templates. We’ll go into more details when we build the frontend:
php composer.phar require slim/php-view
We use the Hello World from the installation page to make sure everything was setup correctly (I changed the path to autoload.php
because the index.php
is in the project root):
index.php
<?php use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Slim\Factory\AppFactory; require __DIR__ . '/vendor/autoload.php'; $app = AppFactory::create(); $app->get('/', function (Request $request, Response $response, $args) { $response->getBody()->write("Hello world!"); return $response; }); $app->run();
Go to localhost to see the output:
Looks good.
Note: The vhost configuration already routes all requests to the front controller (our index.php
). Otherwise you could use an .htaccess
file like this:
RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ index.php [QSA,L]
That’s it for the backend setup.
Frontend
The PHP container already contains node.js and yarn. We still need to add vue.js and webpack and put everything together.
Vue.js
We use yarn to install Vue.js and the Vue router:
yarn add vue vue-router
If not already present, this will also create the package.json
file.
We also need several dev dependencies:
yarn add webpack webpack-cli vue-loader vue-template-compiler html-webpack-plugin @babel/core babel-loader -D
Webpack
The build process is configured using a webpack.config.js
file:
'use strict'; const { VueLoaderPlugin } = require('vue-loader'); const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = { mode: 'development', entry: [ './assets/vue/app.js' ], module: { rules: [ { test: /\.vue$/, use: 'vue-loader' }, { test: /\.js$/, loader: 'babel-loader' }, ] }, resolve: { extensions: ['.vue', '.js'] }, plugins: [ new HtmlWebpackPlugin({ template: './templates/index.html', filename: '../public/index.html', inject: true }), new VueLoaderPlugin(), ] }
The Webpack config sets the entry point to our application: the app.js
file. It defines the loaders for Vue.js and plain JavaScript files. It also allows us to reference Vue.js and Javascript files without extensions.
The most interesting part here is the HtmlWebpackPlugin
. This is used to inject the generated JavaScript files into our HTML template. More on that when we talk about the frontend.
Finally we add some scripts to the package.json
:
"scripts": { "build": "webpack --config webpack.config.js", "watch": "webpack --config webpack.config.js --watch" }
The build
script builds all the frontend stuff, while the watch
script will rebuild everything as soon as a file changes.
The application
Now that we have everything set up, we can finally start building our example application. We’ll build a simple movie library. It allows us to add movies as well as list all movies in the library. We store the data in JSON format in the filesystem.
movies.json
[ { "id": 1, "title": "Marvel's The Avengers", "description": "When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back from the brink of disaster." } ]
Movie data from www.themoviedb.org.
Directory structure
I use a DDD style approach here combined with Hexagonal Architecture (aka Ports and Adapters). In short, we have 3 top level directories within the application core:
- Application
- Domain
- Infrastructure
Application contains our use cases and our ports. Use cases are the features our application offers. These are the entry points to our application. There are 2 use cases here: a Command called AddMovie
and a Query called FindAllMovies
. A Command is a use case which changes data, whereas a Query only reads data.
Ports are interfaces to the Outside World. I only added one interface for a repository which is part of the infrastructure. You might also add ports for the use cases, as in Hexagonal Architecture there are incoming and outgoing ports. Think of them as things our application needs (the infrastructure) and things our application offers (the commands and queries).
Domain contains the domain model with its entities, value objects and aggregates. This contains the core business logic.
Infrastructure contains everything that is not part of the core application but that is needed to interact with the Outside World. Things like database access or file system access or calling external APIs go here. No infrastructure code is allowed within Application or Domain.
We’ll look at the Controller directory a little bit later on.
Domain
The Movie
entity looks like this:
<?php namespace SPA\Domain\Entity; class Movie { /** * @var string */ private $id; /** * @var string */ private $title; /** * @var string */ private $description; /** * Movie constructor. * * @param string $id * @param string $title * @param string $description */ public function __construct(string $id, string $title, string $description) { $this->id = $id; $this->title = $title; $this->description = $description; } /** * @return string */ public function getId(): string { return $this->id; } /** * @return string */ public function getTitle(): string { return $this->title; } /** * @return string */ public function getDescription(): string { return $this->description; } /** * @return string[] */ public function toArray(): array { return [ 'id' => $this->getId(), 'title' => $this->getTitle(), 'description' => $this->getDescription(), ]; } }
Not much going on here. In a more sophisticated application this would of course be much more complex.
Application
As mentioned before there are only 2 use cases in our application:
AddMovie
FindAllMovies
Both rely on the MovieRepositoryInterface
, so we start with that:
MovieRepositoryInterface
<?php namespace SPA\Application\Port; use SPA\Domain\Entity\Movie; interface MovieRepositoryInterface { /** * @return Movie[] */ public function findAll(): array; /** * @param string $title * @param string $description * * @return Movie */ public function addMovie(string $title, string $description): Movie; }
Again, there is no infrastructure code in Application. Instead we are using an interface here. This is enough to build the use cases.
AddMovie
<?php namespace SPA\Core\Application\Command; use SPA\Core\Application\Port\MovieRepositoryInterface; use SPA\Core\Domain\Entity\Movie; class AddMovie { /** * @var MovieRepositoryInterface */ private $movieRepository; /** * AddMovie constructor. * * @param MovieRepositoryInterface $movieRepository */ public function __construct(MovieRepositoryInterface $movieRepository) { $this->movieRepository = $movieRepository; } /** * @param string $title * @param string $description * * @return Movie */ public function execute(string $title, string $description): Movie { return $this->movieRepository->addMovie($title, $description); } }
FindAllMovies
<?php namespace SPA\Core\Application\Query; use SPA\Core\Application\Port\MovieRepositoryInterface; use SPA\Core\Domain\Entity\Movie; class FindAllMovies { /** * @var MovieRepositoryInterface */ private $movieRepository; /** * FindAllMovies constructor. * * @param MovieRepositoryInterface $movieRepository */ public function __construct(MovieRepositoryInterface $movieRepository) { $this->movieRepository = $movieRepository; } /** * @return Movie[] */ public function execute(): array { return $this->movieRepository->findAll(); } }
They are both super simple, as they both only forward the call to the repository.
Infrastructure
The infrastructure only consists of our repository. As mentioned before, we just store the movies in a JSON file. The repository offers methods to add movies and to list all movies from the file.
MovieRepository.php
<?php namespace SPA\Core\Infrastructure\Repository; use SPA\Core\Application\Port\MovieRepositoryInterface; use SPA\Core\Domain\Entity\Movie; class MovieRepository implements MovieRepositoryInterface { /** * @var Movie[] */ private $movies; /** * @var string */ private $path = '/var/www/html/movies.json'; public function __construct() { $this->readFile(); } /** * {@inheritDoc} */ public function findAll(): array { return $this->movies; } /** * {@inheritDoc} */ public function addMovie(string $title, string $description): Movie { $id = count($this->movies) + 1; $this->movies[$id] = new Movie($id, $title, $description); $this->writeFile(); return $this->movies[$id]; } private function readFile(): void { $this->movies = []; $raw = file_get_contents($this->path); if (!empty($raw)) { $data = json_decode($raw, true); foreach ($data as $item) { $this->movies[$item['id']] = new Movie( $item['id'], $item['title'], $item['description'] ); } } } private function writeFile(): void { $movies = array_map(function (Movie $movie) { return $movie->toArray(); }, $this->movies); file_put_contents($this->path, json_encode($movies)); } }
Controllers and Routing
The backend part is basically done now. We’ve build our two use cases as well as the storing and retrieving of the movie data. But the application isn’t really usable. We still need to define routes and link them to the use cases, so we can actually access the features we’ve build.
The Slim Framework is quite flexible: you can define routes and use simple callback functions to describe the behavior. Or you can use controller classes. When using the PHP-DI Slim bridge, PHP-DI will only instantiate the controller classes, when they are actually used. We are also much more flexible in terms of controller parameters, as PHP-DI will inject the required parameters.
Let’s have a look at the controller.
MovieController.php
<?php namespace SPA\Controller; use Slim\Psr7\Request; use Slim\Psr7\Response; use SPA\Core\Application\Command\AddMovie; use SPA\Core\Application\Query\FindAllMovies; use SPA\Core\Domain\Entity\Movie; class MovieController { /** * @var AddMovie */ private $addMovieCommand; /** * @var FindAllMovies */ private $findAllMoviesQuery; /** * MovieController constructor. * * @param AddMovie $addMovieCommand * @param FindAllMovies $findAllMoviesQuery */ public function __construct(AddMovie $addMovieCommand, FindAllMovies $findAllMoviesQuery) { $this->addMovieCommand = $addMovieCommand; $this->findAllMoviesQuery = $findAllMoviesQuery; } /** * @param Request $request * @param Response $response * * @return Response */ public function addMovie(Request $request, Response $response): Response { $body = $request->getParsedBody(); $movie = $this->addMovieCommand->execute($body['title'], $body['description']); return $response->withStatus(201, sprintf('Movie id %s created', $movie->getId())); } /** * @param Response $response * * @return Response */ public function listMovies(Response $response): Response { $movies = $this->findAllMoviesQuery->execute(); $movieData = array_map(function (Movie $movie) { return $movie->toArray(); }, $movies); $payload = json_encode($movieData); $response->getBody()->write($payload); return $response->withHeader('Content-Type', 'application/json'); } }
The controller has two actions which map to our two use cases. The first one is used to add new movies, while the second one lists all existing movies.
The final step for the backend is configuring the Slim application, the DI container and the routing. What sounds like a lot of work are actually only a few lines in our front controller.
index.php
<?php use DI\Bridge\Slim\Bridge; use DI\ContainerBuilder; use SPA\Controller\MovieController; use SPA\Core\Application\Port\MovieRepositoryInterface; use SPA\Core\Infrastructure\Repository\MovieRepository; use function DI\autowire; require __DIR__ . '/vendor/autoload.php'; //set up container, which uses PHP-DI autowiring $builder = new ContainerBuilder(); $builder ->addDefinitions([ //manual interface resolution MovieRepositoryInterface::class => autowire(MovieRepository::class), ]); $container = $builder->build(); //create app $app = Bridge::create($container); //automatically parse data posted in JSON, XML or form encoded format $app->addBodyParsingMiddleware(); //routing $app->get('/api/movies', [MovieController::class, 'listMovies']); $app->post('/api/movies', [MovieController::class, 'addMovie']); $app->run();
Luckily for us PHP-DI supports autowiring of the dependencies.
This saves us a whole lot of configuration. For this to work, we have to create the Slim app through the PHP-DI Slim bridge instead of the AppFactory
. Autowiring is enabled by default, so the container will take care of almost everything. It instantiates our use cases, repository and controller. The only thing we have to add is the resolution of the MovieRepositoryInterface
. Finally, we are adding our two routes. And then we are done with the backend!
A GET request will list all the movies in the library:
GET http://localhost/api/movies HTTP/1.1 200 OK Date: Fri, 01 Jan 2021 17:11:11 GMT Server: Apache/2.4.38 (Debian) X-Powered-By: PHP/7.3.10 Keep-Alive: timeout=5, max=100 Connection: Keep-Alive Transfer-Encoding: chunked Content-Type: application/json { "1": { "id": "1", "title": "Marvel's The Avengers", "description": "When an unexpected enemy emerges and threatens global safety and security, Nick Fury, director of the international peacekeeping agency known as S.H.I.E.L.D., finds himself in need of a team to pull the world back from the brink of disaster." } } Response code: 200 (OK); Time: 210ms; Content length: 306 bytes
While a POST request will add a new movie:
POST http://localhost/api/movies Content-Type: application/x-www-form-urlencoded title=Iron Man&description=After being held captive in an Afghan cave, billionaire engineer Tony Stark creates a unique weaponized suit of armor to fight evil. //response <Response body is empty> Response code: 201 (Movie id 2 created); Time: 210ms; Content length: 0 bytes
Note: The example call shows data posted in form encoded format. But thanks to Slim’s BodyParsingMiddleware we can also post data in JSON or XML format. Nice.
Frontend: The Slim part
The Slim Framework doesn’t have a view layer like an MVC framework. However, there are separate view components for PHP and Twig templates. As most of the frontend is build using Vue.js, we use the PHP view component. We already added it with the backend dependencies.
We need to tell the PHPRenderer were to look for the template files by adding another definition to the container. After that, we can add the route for the frontend.
index.php
<?php /* imports skipped for brevity */ $builder = new ContainerBuilder(); $builder ->addDefinitions([ //manual interface resolution MovieRepositoryInterface::class => autowire(MovieRepository::class), //set template path PhpRenderer::class => autowire()->constructor('public'), ]); $container = $builder->build(); /* build container, create app, add api routes */ //frontend routes $app->get('/', [IndexController::class, 'index']); $app->run();
We only need a single controller to act as the entry point here:
IndexController.php
<?php namespace SPA\Controller; use Psr\Http\Message\ResponseInterface; use Slim\Views\PhpRenderer; class IndexController { /** * @var PhpRenderer */ private $view; /** * IndexController constructor. * * @param PhpRenderer $view */ public function __construct(PhpRenderer $view) { $this->view = $view; } public function index(ResponseInterface $response): ResponseInterface { return $this->view->render($response, 'index.html'); } }
The controller simply renders a static template. The template looks like this:
templates/index.html
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1.0"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous"> <title>SPA with Slim Framework and Vuejs</title> </head> <body> <div id="app"> Here come content </div> <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ho+j7jyWK8fNQe+A12Hb8AhRq26LrZ/JpcUGGOn+Y7RsweNrtN/tE3MoK7ZeZDyx" crossorigin="anonymous"></script> </body> </html>
The template is an mostly empty HTML document. I added Bootstrap CSS and JavaScript, so we have some nice looking components eventually. The div with the id app is where the Vue.js components will be inserted later.
We already talked about this when setting up Webpack: When building the frontend, Webpack will take this template, inject <script>
tags for the generated JavaScript files and write the resulting template to public/index.html
. This file is then used by Slim’s PHPRenderer
when the index route is hit.
Enter Vue.js
The directory structure for the frontend assets looks like this:
We’ve got two views/pages here: MovieList
will be our home page showing all movies in the library. AddMovie
allows us to enter a new movie.
Vue.js routing
As we are building a SPA the Vue.js router offers a very convenient way to navigate between our views. We already added the router during the initial setup. The router config looks like this:
assets/vue/router/index.js
import Vue from "vue" import VueRouter from "vue-router" import MovieList from "../views/MovieList"; import AddMovie from "../views/AddMovie"; Vue.use(VueRouter) export default new VueRouter({ mode: "history", routes: [ { path: "/movies", component: MovieList }, { path: "/add-movie", component: AddMovie }, { path: "*", redirect: "/movies" }, ] })
We’ve got 3 route definitions here: /movies
shows the MovieList
view while /add-movie
shows the AddMovie
view. All other URLs are redirected to /movies
.
As we are using the history mode here, we’ll get nice looking URLs like localhost/movies
or localhost/add-movie
.
Note: There is one small issue here. While the navigation within the Vue.js frontend works completely fine, entering one of the above URLs manually or refreshing the page will give us an error from Slim’s router. That’s because we it doesn’t know this route. To fix this we simply add another route with a variable segment which also calls our IndexController
.
index.php
<?php /* setup container, create app, add api routes */ //frontend routes $app->get('/', [IndexController::class, 'index']); $app->get('/{sth}', [IndexController::class, 'index']); $app->run();
Back to Vue.js. We have to inject the router into the root Vue instance:
assets/vue/app.js
import Vue from "vue" import router from "./router" import App from "./App"; new Vue({ components: { App }, render: h => h(App), router }).$mount("#app")
The root component of our application (App.vue
) consists of basically two things: a navbar so we can easily move between our views by using <router-link>
elements. And a <router-view>
. That’s where the view components will be rendered.
assets/vue/App.vue
<template> <div class="container"> <nav class="navbar navbar-expand-lg navbar-light bg-light"> <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="/movies" active-class="active" > <a class="nav-link">Home</a> </router-link> <router-link class="nav-item" tag="li" to="/add-movie" active-class="active" > <a class="nav-link">Add Movie</a> </router-link> </ul> </div> </nav> <router-view /> </div> </template> <script> export default { name: "App", } </script>
The MovieList view
This will be our home page: a list of all the movies in the library. We use bootstrap’s Card component to display the movies. Let’s build a Vue component which contains the details of a movie nicely styled using a Card.
assets/vue/components/MovieDetails.vue
<template> <div class="col-sm-4"> <div class="card"> <div class="card-body"> <h5 class="card-title">{{ title }}</h5> <p class="card-text">{{ description }}</p> </div> </div> </div> </template> <script> export default { name: "MovieDetails", props: ["title", "description"] } </script>
The view component will iterate over the movies and display them using the MovieDetails
component:
assets/vue/views/MovieList.vue
<template> <div> <h1>Movie List</h1> <div class="row"> <MovieDetails v-for="movie in movies" :title="movie.title" :description="movie.description" :key="movie.id" /> </div> </div> </template>
But how do we get those movies? We use Axios to consume the API we’ve build before.
Using Axios to Consume APIs
We start by adding one more dependency:
yarn add axios
We add the call to fetch the movies to the created hook. This way the data will be fetched every time we navigate to the MovieList
view.
assets/vue/views/MovieList.vue
<template> <div> <h1>Movie List</h1> <div class="row"> <MovieDetails v-for="movie in movies" :title="movie.title" :description="movie.description" :key="movie.id" /> </div> </div> </template> <script> import MovieDetails from "../components/MovieDetails" import axios from "axios" export default { name: "MovieList", components: {MovieDetails}, data() { return { movies: [] } }, created: function () { axios .get('/api/movies') .then(response => (this.movies = response.data)) } } </script>
Note: I omitted error handling and the dreaded loader icons to keep this simple.
Adding movies
The second and final view is for adding new movies. We need a form with a couple inputs to enter the movie data. The we’ll use Axios again to post the data to our API.
assets/vue/views/AddMovie.vue
<template> <div> <h1>Add Movie</h1> <div v-if="saved" class="alert alert-success" role="alert"> Movie saved </div> <div class="form-group"> <label for="title"> Title </label> <input type="text" class="form-control" id="title" v-model="title" @input="saved = false"/> </div> <div class="form-group"> <label for="description"> Description </label> <input type="text" class="form-control" id="description" v-model="description" @input="saved = false"/> </div> <button class="btn btn-primary" @click="submit"> Submit </button> </div> </template> <script> import axios from "axios" export default { name: "AddMovie", data() { return { title: null, description: null, saved: false } }, methods: { submit() { this.saved = false if (this.title && this.description) { axios .post('api/movies', { title: this.title, description: this.description }) .then(() => { this.saved = true this.title = null this.description = null }) } } } } </script>
Again, you would add some error handling in a more sophisticated application. But this – hopefully – gets the idea across.
Conclusion
And that’s it. In this tutorial we have set up a single page application. We have built the backend using light weight Slim Framework and used Vue.js for the frontend.
We’ll revisit this project in a series about static analysis.