Building a Laravel project

This tutorial will show how to create a simple Laravel project on Alpine using PHP7, MySQL and Redis.

Create the project

In order to create the initial project structure, we will need a container with the Laravel installer. First, let’s create a directory for our project:

$ mkdir -p ~/projects/vagga-laravel-tutorial && cd ~/projects/vagga-laravel-tutorial

Create the vagga.yaml file and add the following to it:

containers:
  app:
    setup:
    - !Alpine v3.5
    - !Repo community
    - !Install
      - ca-certificates
      - php7
      - php7-openssl
      - php7-mbstring
      - php7-phar
      - php7-json
    - !ComposerConfig
      install-runtime: false
      runtime-exe: /usr/bin/php7
      keep-composer: true
    - !ComposerInstall
    environ:
      HOME: /tmp

Here we are building a container from Alpine v3.5 and telling it to install PHP7 and everything needed to run Composer. Now let’s create our new project:

$ vagga _run app composer create-project \
    --prefer-dist --no-install --no-scripts \
    laravel/laravel src 5.4.*
$ mv src/* src/.* .
$ rmdir src

The first command is quite big! It tells composer to create a new project from laravel/laravel version 5.4 and place it into the src directory. The three flags tell composer to:

  • --prefer-dist install packages from distribution source when available;
  • --no-install do not run composer install after creating the project;
  • --no-scripts do not run scripts defined in the root package.

We want our project’s files to be in the current directory (the one containing vagga.yaml) but Composer only accepts an empty directory, so we tell it to create the project into src, move its contents into the current directory and remove src.

Now that we have our project created, change our container as follows:

containers:
  app-base:
    setup:
    - !Alpine v3.5
    - !Repo community
    - !Install
      - ca-certificates
      - php7
      - php7-openssl
      - php7-pdo_mysql
      - php7-mbstring
      - php7-xml
      - php7-session
      - php7-dom
      - php7-phar
      - php7-json
      - php7-posix
      - php7-ctype
    - !Sh ln -s /usr/bin/php7 /usr/bin/php
  app:
    environ: &env
      APP_ENV: development
      APP_DEBUG: true
      APP_KEY: YourRandomGeneratedEncryptionKey
    setup:
    - !Container app-base
    - !Env { <<: *env }
    - !ComposerConfig
      install-runtime: false
      runtime-exe: /usr/bin/php7
      keep-composer: true
    - !EnsureDir /work/vendor
    - !EnsureDir /usr/local/lib/composer/vendor
    - !Sh mount --bind,ro /usr/local/lib/composer/vendor /work/vendor
    - !ComposerDependencies
    - !Sh umount /work/vendor
    volumes:
      /work/vendor: !BindRO /vagga/root/usr/local/lib/composer/vendor

This might look complex, but let’s break it down:

app-base:
  setup:
  - !Alpine v3.5
  - !Repo community
  - !Install
    - ca-certificates
    - php7
    - php7-openssl
    - php7-pdo_mysql
    - php7-mbstring
    - php7-xml
    - php7-session
    - php7-dom
    - php7-phar
    - php7-json
    - php7-posix
    - php7-ctype
  - !Sh ln -s /usr/bin/php7 /usr/bin/php

The container for our application is based on Alpine linux v3.5 and we will use PHP7, so we need to enable the “community” repository from Alpine and install php7 and the modules needed for both Laravel and Composer.

We also link the php7 executable into /usr/bin/php to make it available as just php.

This container will be used as the base for the app container in order to speed up builds.

environ: &env
  APP_ENV: development
  APP_DEBUG: true
  APP_KEY: YourRandomGeneratedEncryptionKey

Here we are configuring our application. Laravel comes out of the box with its configuration done through environment variables, so we are setting these to what we need to a development environment. The default project template uses dotenv to load configuration into environment automatically from a .env file, but we won’t use that. Instead, we tell vagga to set the environment for us.

We are also setting and yaml anchor (&env) so we can reference it later.

setup:
- !Container app-base
- !Env { <<: *env }

We are extending the app-base container and referencing the yaml anchor we defined earlier to make the environment available during build.

- !ComposerConfig
  install-runtime: false
  runtime-exe: /usr/bin/php7
  keep-composer: true

Since we installed php by ourselves, we tell vagga to use version we installed instead of the default version from Alpine.

- !EnsureDir /work/vendor
- !EnsureDir /usr/local/lib/composer/vendor
- !Sh mount --bind,ro /usr/local/lib/composer/vendor /work/vendor
- !ComposerDependencies
- !Sh umount /work/vendor

The composer.json created by Laravel has some post install hooks that expect the vendor directory to be available at the project root, but vagga installs composer dependencies under /usr/local/lib/composer. To make it available to our application during the build, we mount that directory into /work/vendor and umount afterwards.

To test if everything is ok, let’s add a command to run our project:

containers:
  # ...
commands:
  run: !Command
    container: app
    description: run the laravel development server
    run: |
        php artisan cache:clear ❶
        php artisan config:clear ❶
        php artisan serve
  • ❶ – clear application cache to prevent previous runs from intefering on subsequent runs.

Now run our project:

$ vagga run

And visit localhost:8000. If everithing is OK, you will see Laravel default page saying “Laravel 5”.

Setup the database

Every PHP project needs a database, and ours is not different, so let’s create a container for our database:

containers:
  # ...
  mysql:
    setup:
    - !Ubuntu xenial
    - !UbuntuUniverse
    - !Sh |
        addgroup --system --gid 200 mysql ❶
        adduser --uid 200 --system --home /data --no-create-home \
            --shell /bin/bash --group --gecos "MySQL user" \
            mysql
    - !Install
      - mysql-server-5.7
      - mysql-client-5.7
    - !Remove /var/lib/mysql
    - !EnsureDir /data
    environ: &db_config 
      DB_DATABASE: vagga
      DB_USERNAME: vagga
      DB_PASSWORD: vagga
      DB_HOST: 127.0.0.1
      DB_PORT: 3307
      DB_DATA_DIR: /data
    volumes:
      /data: !Persistent
        name: mysql
        owner-uid: 200
        owner-gid: 200
        init-command: _mysql-init ❸
      /run: !Tmpfs
        subdirs:
          mysqld: { mode: 0o777 }
  • ❶ – Use fixed user id and group id for mysql
  • ❷ – Put an anchor at the database environment so we can reference it later
  • ❸ – Vagga command to initialize the volume

Note

The database will be persisted in .vagga/.volumes/mysql.

Add the command to initialize the database:

commands:
  # ...
  _mysql-init: !Command
    description: Init MySQL data volume
    container: mysql
    user-id: 200
    group-id: 200
    run: |
      set -ex

      mysqld --initialize-insecure --datadir=$DB_DATA_DIR \
        --log-error=log

      mysqld --datadir=$DB_DATA_DIR --skip-networking --log-error=log &

      while [ ! -S /run/mysqld/mysqld.sock ]; do sleep 0.2; done

      mysqladmin -u root create $DB_DATABASE
      mysql -u root -e "CREATE USER '$DB_USERNAME'@'localhost' IDENTIFIED BY '$DB_PASSWORD';"
      mysql -u root -e "GRANT ALL PRIVILEGES ON $DB_DATABASE.* TO '$DB_USERNAME'@'localhost';"
      mysql -u root -e "FLUSH PRIVILEGES;"

      mysqladmin -u root shutdown

Add a the php mysql module to our container:

containers:
  app-base:
    - !Alpine v3.5
    - !Repo community
    - !Install
      - ca-certificates
      - php7
      # ...
      - php7-pdo_mysql # mysql module
    # ...

Now change our run command to start the database alongside our project:

commands:
  run: !Supervise
    description: run the laravel development server
    children:
      app: !Command
        container: app
        environ: *db_config 
        run: |
            php artisan cache:clear
            php artisan config:clear
            php artisan serve
      db: !Command
        container: mysql
        user-id: 200
        group-id: 200
        run: |
          exec mysqld --datadir=$DB_DATA_DIR \
            --bind-address=$DB_HOST --port=$DB_PORT \
            --log-error=log --gdb
  • ❶ – Reference the database environment

And run our project:

$ vagga run

Inspecting the database

Now that we have a working database, we can inspect it using a small php utility called adminer. Let’s create a container for it:

containers:
  # ...
  adminer:
    setup:
    - !Alpine v3.5
    - !Repo community
    - !Install
      - php7
      - php7-pdo_mysql
      - php7-session
    - !EnsureDir /opt/adminer
    - !EnsureDir /opt/adminer/plugins
    - !Download
      url: https://www.adminer.org/static/download/4.2.5/adminer-4.2.5-mysql.php ❶
      path: /opt/adminer/adminer.php
    - !Download
      url: https://raw.github.com/vrana/adminer/master/designs/nette/adminer.css ❷
      path: /opt/adminer/adminer.css
    - !Download
      url: https://raw.github.com/vrana/adminer/master/plugins/plugin.php ❸
      path: /opt/adminer/plugins/plugin.php
    - !Download
      url: https://raw.github.com/vrana/adminer/master/plugins/login-servers.php ❹
      path: /opt/adminer/plugins/login-servers.php
    - !Text
      /opt/adminer/index.php: |
          <?php ❺
          function adminer_object() {
              include_once "./plugins/plugin.php";
              foreach (glob("plugins/*.php") as $filename) { include_once "./$filename"; }
              $plugins = [new AdminerLoginServers(['127.0.0.1:3307' => 'Dev DB'])];
              return new AdminerPlugin($plugins);
          }
          include "./adminer.php";
  • ❶ – download the adminer script.
  • ❷ – use a better style (optional).
  • ❸ – adminer plugin support
  • ❹ – login-servers plugin to avoid typing server address and port
  • ❺ – setup adminer

The container above will install PHP7 along with the mysql and session modules, then it will download adminer itself, the optional style, the plugin support and the “login-servers” plugin. This plugin will allow us to select the database we are connecting to from a list instead of filling in the host and port. The last part of the container setup configures adminer with our database.

Now change our run command to start the adminer container:

commands:
  run: !Supervise
    description: run the laravel development server
    children:
      app: !Command
        # ...
      db: !Command
        # ...
      adminer: !Command
        container: adminer
        run: php7 -S 127.0.0.1:8001 -t /opt/adminer

This command will start the php embedded server with its root pointing to the directory we setup for Adminer.

To access adminer, visit localhost:8001 and fill the username and password fields with “vagga”.

Adding some code

Now that we have our project working and our database is ready, let’s add some.

Let’s add a shortcut command for running artisan

commands:
  # ...
  artisan: !Command
    description: Shortcut for running artisan cli
    container: app
    run: [php, artisan]

Now, we need a layout. Fortunately, Laravel can give us one, we just have to scaffold authentication:

$ vagga artisan make:auth

This will give us a nice layout at resources/views/layouts/app.blade.php.

Now create a model:

$ vagga artisan make:model --migration Article

This will create a new model at app/Article.php and its respective migration at database/migrations/2016_03_24_172211_create_articles_table.php (yours will have a slightly different name).

Open the migration file and tell it to add two fields, title and body, to the database table for our Article model:

<?php

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateArticlesTable extends Migration
{
    public function up()
    {
        Schema::create('articles', function (Blueprint $table) {
            $table->increments('id');
            $table->string('title', 100);
            $table->text('body');
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::drop('articles');
    }
}

Open routes/web.php and setup routing:

<?php
Route::get('/', 'ArticleController@index');
Route::resource('/article', 'ArticleController');

Auth::routes();

Route::get('/home', 'HomeController@index');

Create our controller:

$ vagga artisan make:controller --resource ArticleController

This will create a controller at app/Http/Controllers/ArticleController.php populated with some CRUD method stubs.

Now change the controller to actually do something:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

use App\Article;

class ArticleController extends Controller
{
    public function index()
    {
        $articles = Article::orderBy('created_at', 'asc')->get();
        return view('article.index', [
           'articles' => $articles
        ]);
    }

    public function create()
    {
        return view('article.create');
    }

    public function store(Request $request)
    {
        $this->validate($request, [
            'title' => 'required|max:100',
            'body' => 'required'
        ]);

        $article = new Article;
        $article->title = $request->title;
        $article->body = $request->body;
        $article->save();

        return redirect('/');
    }

    public function show($id)
    {
        $article = Article::find($id);
        return view('article.show', [
            'article' => $article
        ]);
    }

    public function edit(Article $article)
    {
        return view('article.edit', [
            'article' => $article
        ]);
    }

    public function update(Request $request, Article $article)
    {
        $article->title = $request->title;
        $article->body = $request->body;
        $article->save();

        return redirect('/');
    }

    public function destroy(Article $article)
    {
        $article->delete();
        return redirect('/');
    }
}

Create the views for our controller:

<!-- resources/views/article/show.blade.php -->
@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row">
        <div class="col-md-8 col-md-offset-2">
            <h2>{{ $article->title }}</h2>
            <p>{{ $article->body }}</p>
        </div>
    </div>
</div>
@endsection
<!-- resources/views/article/index.blade.php -->
@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row">
        <div class="col-md-8 col-md-offset-2">
            <h2>Article List</h2>
            <a href="{{ url('article/create') }}" class="btn">
                <i class="fa fa-btn fa-plus"></i>New Article
            </a>
            @if (count($articles) > 0)
            <table class="table table-bordered table-striped">
                <thead>
                    <th>id</th>
                    <th>title</a></th>
                    <th>actions</th>
                </thead>
                <tbody>
                    @foreach($articles as $article)
                    <tr>
                        <td>{{ $article->id }}</td>
                        <td>{{ $article->title }}</td>
                        <td>
                            <a href="{{ url('article/'.$article->id) }}" class="btn btn-success">
                                <i class="fa fa-btn fa-eye"></i>View
                            </a>
                            <a href="{{ url('article/'.$article->id.'/edit') }}" class="btn btn-primary">
                                <i class="fa fa-btn fa-pencil"></i>Edit
                            </a>
                            <form action="{{ url('article/'.$article->id) }}"
                                    method="post" style="display: inline-block">
                                {!! csrf_field() !!}
                                {!! method_field('DELETE') !!}
                                <button type="submit" class="btn btn-danger"
                                        onclick="if (!window.confirm('Are you sure?')) { return false; }">
                                    <i class="fa fa-btn fa-trash"></i>Delete
                                </button>
                            </form>
                        </td>
                    </tr>
                    @endforeach
                </tbody>
            </table>
            @endif
        </div>
    </div>
</div>
@endsection
<!-- resources/views/article/create.blade.php -->
@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row">
        <div class="col-md-8 col-md-offset-2">
            <h2>Create Article</h2>
            @include('common.errors')
            <form action="{{ url('article') }}" method="post">
                {!! csrf_field() !!}
                <div class="form-group">
                    <label for="id-title">Title:</label>
                    <input id="id-title" class="form-control" type="text" name="title" />
                </div>
                <div class="form-group">
                    <label for="id-body">Title:</label>
                    <textarea id="id-body" class="form-control" name="body"></textarea>
                </div>
                <button type="submit" class="btn btn-primary">Save</button>
            </form>
        </div>
    </div>
</div>
@endsection
<!-- resources/views/article/edit.blade.php -->
@extends('layouts.app')

@section('content')
<div class="container">
    <div class="row">
        <div class="col-md-8 col-md-offset-2">
            <h2>Edit Article</h2>
            @include('common.errors')
            <form action="{{ url('article/'.$article->id) }}" method="post">
                {!! csrf_field() !!}
                {!! method_field('PUT') !!}
                <div class="form-group">
                    <label for="id-title">Title:</label>
                    <input id="id-title" class="form-control"
                           type="text" name="title" value="{{ $article->title }}" />
                </div>
                <div class="form-group">
                    <label for="id-body">Title:</label>
                    <textarea id="id-body" class="form-control" name="body">{{ $article->body }}</textarea>
                </div>
                <button type="submit" class="btn btn-primary">Save</button>
            </form>
        </div>
    </div>
</div>
@endsection

And the view for the common errors:

<!-- resources/views/common/errors.blade.php -->
@if (count($errors) > 0)
<div class="alert alert-danger">
    <ul>
        @foreach ($errors->all() as $error)
            <li>{{ $error }}</li>
        @endforeach
    </ul>
</div>
@endif

Create a seeder to prepopulate our database:

$ vagga artisan make:seeder ArticleSeeder

This will create a seeder class at database/seeds/ArticleSeeder.php. Open it and change it as follows:

<?php

use Illuminate\Database\Seeder;

use App\Article;

class ArticleSeeder extends Seeder
{
    private $articles = [
        ['title' => 'Article 1', 'body' => 'Lorem ipsum dolor sit amet'],
        ['title' => 'Article 2', 'body' => 'Lorem ipsum dolor sit amet'],
        ['title' => 'Article 3', 'body' => 'Lorem ipsum dolor sit amet'],
        ['title' => 'Article 4', 'body' => 'Lorem ipsum dolor sit amet'],
        ['title' => 'Article 5', 'body' => 'Lorem ipsum dolor sit amet']
    ];

    public function run()
    {
        if (Article::all()->count() > 0) {
            return;
        }

        foreach ($this->articles as $article) {
            $new = new Article;
            $new->title = $article['title'];
            $new->body = $article['body'];
            $new->save();
        }
    }
}

Change database/seeds/DatabaseSeeder.php to include ArticleSeeder:

<?php
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run()
    {
        $this->call(ArticleSeeder::class);
    }
}

Change the run command to execute the migrations and seed our database:

commands:
  run: !Supervise
    description: run the laravel development server
    children:
      app: !Command
        container: laravel
        environ: *db_config
        run: |
          # wait for database to be ready before starting
          dsn="mysql:host=$DB_HOST;port=$DB_PORT"
          while ! php -r "new PDO('$dsn', '$DB_USERNAME', '$DB_PASSWORD');" 2> /dev/null; do
            echo 'Waiting for database'
            sleep 2
          done

          php artisan cache:clear
          php artisan config:clear
          php artisan migrate
          php artisan db:seed
          php artisan serve
      db: !Command
        # ...
      adminer: !Command
        # ...

If you run our project, you will see the articles we defined in the seeder class. Try adding some articles, then access adminer at localhost:8001 to inspect the database.

Setup Redis

Laravel can make use of redis to perform tasks like queues and events. In our project, we will use it to cache data from the database. First, let’s create a command to call composer:

commands:
  # ...
  composer: !Command
    container: app
    description: run compose cli
    environ: 
      COMPOSER_HOME: /usr/local/lib/composer
      COMPOSER_VENDOR_DIR: /usr/local/lib/composer/vendor
      COMPOSER_CACHE_DIR: /tmp
      COMPOSER_ALLOW_SUPERUSER: 1
    volumes:
      /usr/local/lib/composer/vendor: !Tmpfs 
      /tmp: !CacheDir composer-cache ❸
    run: [/usr/local/bin/composer]
  • ❶ – setup composer home, vendor dir, cache dir and allow running as root
  • ❷ – mount directory as Tmpfs to make it writeable
  • ❸ – mount composer cache directory

This command setup the environment needed by composer to run properly and mount the composer cache volume to avoid downloading cached packages. The directory /usr/local/lib/composer/vendor needs to be writeable (composer will will put packages there) so we mount it as Tmpfs.

Now let’s install predis/predis:

$ vagga composer require predis/predis

With predis installed, we can proceed to create a container for Redis:

containers:
  redis:
    setup:
    - !Alpine v3.5
    - !Install [redis]

Add some yaml anchors on the run command so we can avoid repetition:

commands:
  run: !Supervise
    description: run the laravel development server
    children:
      app: !Command
        container: app
        environ: *db_config
        run: &app_cmd | # ❶
            # ...
      db: &db_cmd !Command 
        # ...
      adminer: &adminer_cmd !Command 
        # ...
  • ❶ – set an anchor at the app child command
  • ❷ – set an anchor at the db child command
  • ❸ – set an anchor at the adminer child command

Create the command to run with caching:

commands:
  # ...
  run-cached: !Supervise
    description: Start the laravel development server alongside redis
    children:
      cache: !Command
        container: redis
        run: redis-server --daemonize no --port 6380 --loglevel verbose ❶
      app: !Command
        container: app
        environ:
          <<: *db_config
          CACHE_DRIVER: redis
          REDIS_HOST: 127.0.0.1
          REDIS_PORT: 6380
        run: *app_cmd
      db: *db_cmd
      adminer: *adminer_cmd
  • ❶ – run redis as verbose so we see can see the cache working

Now let’s change our controller to use caching:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

use App\Article;

use Cache;

class ArticleController extends Controller
{
    public function index()
    {
        $articles = Cache::rememberForever('article:all', function() {
            return Article::orderBy('created_at', 'asc')->get();
        });
        return view('article.index', [
           'articles' => $articles
        ]);
    }

    public function create()
    {
        return view('article.create');
    }

    public function store(Request $request)
    {
        $this->validate($request, [
            'title' => 'required|max:100',
            'body' => 'required'
        ]);

        $article = new Article;
        $article->title = $request->title;
        $article->body = $request->body;
        $article->save();

        Cache::forget('article:all');

        return redirect('/');
    }

    public function show($id)
    {
        $article = Cache::rememberForever('article:'.$id, function() use ($id) {
            return Article::find($id);
        });
        return view('article.show', [
            'article' => $article
        ]);
    }

    public function edit(Article article)
    {
        return view('article.edit', [
            'article' => $article
        ]);
    }

    public function update(Request $request, Article $article)
    {
        $article->title = $request->title;
        $article->body = $request->body;
        $article->save();

        Cache::forget('article:'.$article->id);
        Cache::forget('article:all');

        return redirect('/');
    }

    public function destroy(Article $article)
    {
        $article->delete();
        Cache::forget('article:'.$article->id);
        Cache::forget('article:all');
        return redirect('/');
    }
}

Now run our project with caching:

$ vagga run-cached

Keep an eye on the console to see Laravel talking to redis, you will see something like:

3:M 15 Mar 15:20:06.418 - DB 0: 5 keys (0 volatile) in 8 slots HT.