A single-page application (SPA) with Slim Framework and Vue.js

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.

Slim is a PHP micro framework that helps you quickly write simple yet powerful web applications and APIs.

www.slimframework.com

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.

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:

Hello world

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

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.

Autowiring is an exotic word that represents something very simple: the ability of the container to automatically create and inject dependencies.

php-di.org

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:

Directory Structure

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.