Moving beyond Envoy

Laravel Envoy (version 5.4) was a nice leap forward from Rocketeer, which ported Capistrano’s zero downtime deploy to PHP. Laravel’s response, Envoy, is a “do it yourself” minimalist tool compared to Rocketeer’s pre-built recipes that were almost plug-and-play.

Working with one or two projects, using a service like Envoyer to deploy sites isn’t a problem. But when the number of projects hits over 100, the cost becomes a burden. Using a per project deployment is the only cost effective approach.

Requirements

Zero downtime deploy with ability to rollback

Build on local (or CI machine) and rsync to server(s)

Passing tests before deploy

Deploy to both development and production environments

Minimal overhead per project

Directory structure

├── current -> releases/9

├── releases

│ ├── 7

│ │ ├── ...

│ ├── 8

│ │ ├── ...

│ └── 9

│ ├── app

│ ├── artisan

│ ├── bootstrap

│ ├── composer.json

│ ├── composer.lock

│ ├── config

│ ├── database

│ ├── package.json

│ ├── phpunit.xml

│ ├── public

│ ├── readme.md

│ ├── resources

│ ├── routes

│ ├── server.php

│ ├── storage -> ../../shared/storage

│ ├── tests

│ ├── vendor

│ ├── .env -> ../../shared/.env

│ └── webpack.mix.js

└── shared

└── .env

└── storage

├── app

├── framework

└── logs

Existing Envoy deploy

A single file, Envoy.blade.php , 300 lines (or more) with literal commands that rely on Blade to store and output variables. A mix of PHP, Blade and SSH feels like an uncomfortable mesh of concerns.

A single task is a shell script written in Blade:

@task('link_newrelease_on_remote', ['on' => $remote_server])

echo "Deploy new release link";

cd {{ $app_base }};

[ -d {{ $prev_dir }} ] && unlink {{ $prev_dir }};

[ -d {{ $app_dir }} ] && mv {{ $app_dir }} {{ $prev_dir }};

ln -nfs {{ $release_dir }}/{{ $release }} {{ $app_dir }} && chgrp -h {{$serviceowner}} {{ $app_dir }};

echo "Deployment ({{ $release }}) symbolic link created";

@endtask

The inability to extract these self contained tasks into PSR loaded files is more irritating than it should be.

Deployer to the rescue

Example deployer.org deploy

Deployer (version 4.3) takes the modern PHP approach to deployment with a PSR loaded package, object-oriented, self contained and reusable throughout any deployment strategy.

It is framework independent and easily extendable.

Although a ‘do it yourself” option is available, it comes packaged with starter scripts for Laravel, Symfony, Yii, Zend, CakePHP, CodeIgniter, and Drupal. Additionally, a host of “Recipes” are maintained independently for specific deploy tasks.

Expanding on the base Laravel deploy

The base Laravel deploy is pretty basic and would work perfect for most use cases, but clones and builds on the remote servers. Let’s combine it with the Local, RSYNC and NPM builds to shift all build requirements to the continuous integration (CI) server. This frees up the software needed on the production servers and ensures tests are passing before deploy.

Install Deployer and recipes

composer require deployer/deployer --dev

composer require deployer/recipes --dev

Initialize

dep init

Resulting /deploy.php script

<?php

namespace Deployer; require 'recipe/laravel.php';



// Configuration

set('ssh_type', 'native');

set('ssh_multiplexing', true);

set('repository', 'git@domain.com:username/repository.git');



add('shared_files', []);

add('shared_dirs', []);

add('writable_dirs', []);



// Servers

server('production', 'domain.com')

->user('username')

->identityFile()

->set('deploy_path', '/var/www/domain.com')

->pty(true);





// Tasks

desc('Restart PHP-FPM service');

task('php-fpm:restart', function () {

// The user must have rights for restart service

// /etc/sudoers: username ALL=NOPASSWD:/bin/systemctl restart php-fpm.service

run('sudo systemctl restart php-fpm.service');

});

after('deploy:symlink', 'php-fpm:restart');



// [Optional] if deploy fails automatically unlock.

after('deploy:failed', 'deploy:unlock');



// Migrate database before symlink new release.

before('deploy:symlink', 'artisan:migrate');

Adding local clone and rsync to server

<?php

namespace Deployer; require 'recipe/laravel.php';

require 'vendor/deployer/recipes/local.php';

require 'vendor/deployer/recipes/rsync.php'; // Configuration

set('ssh_type', 'native');

set('ssh_multiplexing', true);

set('writable_mode', 'chmod');

set('default_stage', 'dev');

set('local_deploy_path', '/tmp/deployer'); ... // RSYNC files from /tmp/deployer

set('rsync_src', function() {

$local_src = get('local_release_path');

if(is_callable($local_src)){

$local_src = $local_src();

}

return $local_src;

}); // Servers

server('production', 'domain.com')

->user('username')

->identityFile()

->set('branch', 'master')

->set('deploy_path', '/var/www/domain.com')

->stage('production');



server('dev', 'dev.domain.com')

->user('username')

->identityFile()

->set('branch', 'develop')

->set('deploy_path', '/var/www/domain.com')

->stage(['dev', 'production']); // Tasks

task('deploy', [

'local:prepare', // Create dirs locally

'local:release', // Release number locally

'local:update_code', // git clone locally

'local:vendors', // composer install locally

'local:symlink', // Symlink /current locally

'deploy:prepare', // Create dirs on server

'deploy:lock', // Lock deploys on server

'deploy:release', // Release number on server

'rsync', // Send files to server

'deploy:writable', // Ensure paths are writable on server

'deploy:shared', // Shared and .env linking on server

'artisan:view:clear', // Optimze on server

'artisan:cache:clear', // Optimze on server

'artisan:config:cache', // Optimze on server

'artisan:optimize', // Optimze on server

'artisan:migrate', // Migrate DB on server

'deploy:symlink', // Symlink /current on server

'deploy:unlock', // Unlock deploys on server

'cleanup', // Cleanup old releases on server

'local:cleanup' // Cleanup old releases locally

])->desc('Deploy your project'); ...

Adding in NPM install and Laravel Mix build

<?php

namespace Deployer; require 'recipe/laravel.php';

require 'vendor/deployer/recipes/local.php';

require 'vendor/deployer/recipes/rsync.php';

require 'vendor/deployer/recipes/npm.php'; ... add('rsync', [

'exclude' => [

'.git',

'deploy.php',

'node_modules',

],

]); ... // Build assets locally

task('npm:local:build', function () {

runLocally("cd {{local_release_path}} && {{local/bin/npm}} run production");

}); // Tasks

task('deploy', [

'local:prepare', // Create dirs locally

'local:release', // Release number locally

'local:update_code', // git clone locally

'local:vendors', // composer install locally

'npm:local:install', // npm install locally

'npm:local:build', // Build locally

'local:symlink', // Symlink /current locally

...

'cleanup', // Cleanup old releases on server

'local:cleanup' // Cleanup old releases locally

])->desc('Deploy your project'); ...

Run phpunit tests before deploy

<?php ... // Run tests

task('local:phpunit', function () {

runLocally("cd {{local_release_path}} && phpunit");

}); // Tasks

task('deploy', [

'local:prepare', // Create dirs locally

...

'local:update_code', // git clone locally

'local:vendors', // composer install locally

'local:phpunit', // phpunit tests locally

'npm:local:install', // npm install locally

'npm:local:build', // Build locally

'rsync', // Send files to server

...

Putting it all together, the final script

<?php

namespace Deployer; require 'recipe/laravel.php';

require 'vendor/deployer/recipes/local.php';

require 'vendor/deployer/recipes/rsync.php';

require 'vendor/deployer/recipes/npm.php'; // Configuration

set('ssh_type', 'native');

set('ssh_multiplexing', true);

set('writable_mode', 'chmod');

set('default_stage', 'dev'); set('repository', ' git@domain.com :username/repository.git'); add('shared_files', []);

add('shared_dirs', []);

add('writable_dirs', []);

add('rsync', [

'exclude' => [

'.git',

'deploy.php',

'node_modules',

],

]); // RSYNC files from /tmp/deployer instead of vendor/deployer/recipes/

set('rsync_src', function() {

$local_src = get('local_release_path');

if(is_callable($local_src)){

$local_src = $local_src();

}

return $local_src;

}); // Servers

server('production', 'domain.com')

->user('username')

->identityFile()

->set('branch', 'master')

->set('deploy_path', '/var/www/domain.com')

->stage('production');



server('dev', 'dev.domain.com')

->user('username')

->identityFile()

->set('branch', 'develop')

->set('deploy_path', '/var/www/domain.com')

->stage(['dev', 'production']); // Build assets locally

task('npm:local:build', function () {

runLocally("cd {{local_release_path}} && {{local/bin/npm}} run production");

}); // Run tests

task('local:phpunit', function () {

runLocally("cd {{local_release_path}} && {{phpunit}}");

}); // Tasks

task('deploy', [

'local:prepare', // Create dirs locally

'local:release', // Release number locally

'local:update_code', // git clone locally

'local:vendors', // composer install locally

'local:phpunit', // phpunit tests locally

'npm:local:install', // npm install locally

'npm:local:build', // Build locally

'local:symlink', // Symlink /current locally

'deploy:prepare', // Create dirs on server

'deploy:lock', // Lock deploys on server

'deploy:release', // Release number on server

'rsync', // Send files to server

'deploy:writable', // Ensure paths are writable on server

'deploy:shared', // Shared and .env linking on server

'artisan:view:clear', // Optimze on server

'artisan:cache:clear', // Optimze on server

'artisan:config:cache', // Optimze on server

'artisan:optimize', // Optimze on server

'artisan:migrate', // Migrate DB on server

'deploy:symlink', // Symlink /current on server

'deploy:unlock', // Unlock deploys on server

'cleanup', // Cleanup old releases on server

'local:cleanup' // Cleanup old releases locally

])->desc('Deploy your project'); // [Optional] if deploy fails automatically unlock.

after('deploy:failed', 'deploy:unlock');

View full deploy.php as gist

Terminal output of deploy

Build and deploy to development

dep deploy

Build and deploy to both development and production

dep deploy production

Add Verbosity

dep deploy production -vv

Rollback to previous release

dep rollback

Result

The resulting file is ~90 lines of mostly configuration and two custom functions. It is clear what the script does and gets the benefit of updating as the package evolves.

Although every environment is different, with this starter script it is possible to add more build steps, more test requirements and additional servers.