PHPackages                             forrest79/deploy-php - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [DevOps &amp; Deployment](/categories/devops)
4. /
5. forrest79/deploy-php

ActiveLibrary[DevOps &amp; Deployment](/categories/devops)

forrest79/deploy-php
====================

Simple assets builder and deploy application helper for PHP projects.

v1.6.1(1w ago)085BSD-3-ClausePHPPHP ^8.3CI passing

Since Oct 23Pushed yesterday1 watchersCompare

[ Source](https://github.com/forrest79/deploy-php)[ Packagist](https://packagist.org/packages/forrest79/deploy-php)[ RSS](/packages/forrest79-deploy-php/feed)WikiDiscussions master Synced yesterday

READMEChangelogDependencies (14)Versions (39)Used By (0)

Forrest79/DeployPhp
===================

[](#forrest79deployphp)

[![Latest Stable Version](https://camo.githubusercontent.com/b94b1eb569f3d55eca936a290b46be88fb4f42b493d17b5cb470d99d0f5ea2d3/68747470733a2f2f706f7365722e707567782e6f72672f666f727265737437392f6465706c6f792d7068702f76)](//packagist.org/packages/forrest79/deploy-php)[![Monthly Downloads](https://camo.githubusercontent.com/92f2c8e2325f9fb153a327944bcbc7434e5ed945f2f34dd89ef80565983b835f/68747470733a2f2f706f7365722e707567782e6f72672f666f727265737437392f6465706c6f792d7068702f642f6d6f6e74686c79)](//packagist.org/packages/forrest79/deploy-php)[![License](https://camo.githubusercontent.com/099ccacac242c38a86f03f8f4009b9ec4dfd0a3576bded0e38bf9771ec732f44/68747470733a2f2f706f7365722e707567782e6f72672f666f727265737437392f6465706c6f792d7068702f6c6963656e7365)](//packagist.org/packages/forrest79/deploy-php)[![Build](https://github.com/forrest79/deploy-php/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/forrest79/deploy-php/actions/workflows/build.yml)

Simple assets builder and application deploy helper for PHP projects.

Requirements
------------

[](#requirements)

Forrest79/DeployPhp requires PHP 8.0 or higher.

Installation
------------

[](#installation)

The recommended way to install Forrest79/DeployPhp is through Composer:

```
composer require --dev forrest79/deploy-php
```

Documentation
-------------

[](#documentation)

### Assets

[](#assets)

This is a simple assets builder. Currently, it supports copying files, compiling and minifying [sass](https://sass-lang.com/) files and JavaScript/TypeScript (complex [rollup.js](https://rollupjs.org/) + recommended [Babel](https://babeljs.io/), or [esbuild](https://esbuild.github.io/)) files and in debug environment also generating map files. Besides the assets configuration, you can also pass a `$watch` list of files/directories (not necessarily inside the assets source directory) whose timestamp (debug) or content (production) is included in the hash calculation without producing any output, and an `$unwatch` list of files/directories to exclude from the hash calculation, even when they're inside the assets source directory or one of the watched paths.

For compiling and minifying is required `node.js` with installed `npm` packages `sass`, `rollup` (`babel`) or `esbuild` environment. In Debian or Ubuntu, you can do it like this (`-g` option install package globally in the system, not in your repository):

```
curl -sL https://deb.nodesource.com/setup_15.x | sudo -E bash -
sudo apt-get install -y nodejs

# SASS compiler
npm install sass
#sudo npm install -g sass

# Babel and Rollup (prefer not to install this globally)
npm install rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs rollup-plugin-terser @rollup/plugin-babel @babel/core @babel/preset-env @babel/plugin-transform-runtime core-js

# esbuild
npm install esbuild
#sudo npm install -g esbuild
```

Using is straightforward. Examples show how this works with [Nette Framework](https://github.com/nette/nette). Just create new instance `Forrest79\DeployPhp\Assets` class and pass temp directory, assets source directory and configuration array to constructor. `key` is a directory to process (for `DeployPhp\Assets::COPY`) or target file (for `DeployPhp\Assets::ROLLUP` or `DeployPhp\Assets::ESBUILD`) or directory (for `DeployPhp\Assets::SASS`) for source data and `value` can be simple `DeployPhp\Assets::COPY` which tells to copy this file/directory from source to destination or another `array` with items:

- required `type` - with value `DeployPhp\Assets::COPY` to copy file/directory or `DeployPhp\Assets::SASS` to compile and minify sass to CSS or `DeployPhp\Assets::ROLLUP` to use modern JavaScript environment or `DeployPhp\Assets::ESBUILD` to bundle and minify JavaScript/TypeScript with [esbuild](https://esbuild.github.io/)
- optional `env` - if missing, this item is processed for debug and production environment, or you can specify concrete environment `DeployPhp\Assets::DEBUG` or `DeployPhp\Assets::PRODUCTION`
- required `file` or `files` for `type => DeployPhp\Assets::SASS` - with source file or files to compile and minify
- required `file` for `type => DeployPhp\Assets::ROLLUP` - with source file to process (example configuration is below)
- required `file` for `type => DeployPhp\Assets::ESBUILD` - with source file to bundle
- optional `tsconfig` for `type => DeployPhp\Assets::ESBUILD` - path (relative to the assets source directory) to a `tsconfig.json` to pass to esbuild; if not set, esbuild runs without a `--tsconfig` flag

The next two parameters are callable functions, the first is for reading hash from file, and the second is to write hash to file. In example is shown, how you can write it to neon and use it with Nette DI.

After them come two optional parameters, both lists of file/directory paths:

- `$watch` - files/directories that don't have to be inside the assets source directory - their timestamp (debug) or content (production) is included in the hash calculation, without producing any output. Useful for example for `package-lock.json`, when it's outside the assets source directory.
- `$unwatch` - files/directories to exclude from the hash calculation, even when they're inside the assets source directory or one of the `$watch` paths. Useful for example to exclude a `node_modules` directory that lives inside the assets source directory (e.g. needed there for esbuild/rollup module resolution) from constantly triggering rebuilds.

Last (sixth) parameter is optional and define an array with optional settings. More about this is under the example.

To build assets you need first call `buildDebug($configNeon, $destinationDirectory)` or `buildProduction($configNeon, $destinationDirectory)` method.

- `$configFile` file where will be stored actual assets hash that you can use in your application
- `$destinationDirectory` directory where assets will be built

First builds assets only if there was some changed file and creates new hash from all files timestamps (and also create map files), the second builds assets every time and creates hash from every file content.

#### rollup.js environment with Babel

[](#rollupjs-environment-with-babel)

This is modern JavaScript building configuration. You must prepare `rollup` configuration file in your assets directory:

Create files `assets\rollup.config.js`:

```
import { babel } from '@rollup/plugin-babel';
import commonjs from '@rollup/plugin-commonjs';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import { terser } from 'rollup-plugin-terser';

const config = {
	input: process.env.INPUT_FILE, // source file from PHP settings
	output: [
		{ // this compile file for old browsers
			file: process.env.OUTPUT_FILE.replace('{format}', 'iife'), // output file from PHP settings - string {format} is replaced with iife
			format: 'iife',
			name: 'app',  // you can change this, it's some your identificator
			sourcemap: !!parseInt(process.env.SOURCE_MAP, 10), // this provide source map for DEVEL and not for production
		},
		{ // this complie modules JS for modern browsers
			file: process.env.OUTPUT_FILE.replace('{format}', 'esm'),
			format: 'esm',
			sourcemap: !!parseInt(process.env.SOURCE_MAP, 10),
		}
	],
	plugins: [
		nodeResolve(), // with this, you can import from node_modules
		commonjs(), // this resolve require() function
		babel({ // babel settings
			babelHelpers: 'runtime',
			presets: [
				[
					'@babel/preset-env',
					{
						'bugfixes': true,
						'corejs': '3.9',
						'targets': '>0.25%',
						'useBuiltIns': 'usage',
					}
				]
			],
			plugins: ['@babel/plugin-transform-runtime'],
			exclude: /\/node_modules\/core-js\//, // we must exclude core-js from being transpiled
		}),
		terser(), // minification
	]
};

export default config;
```

In your HTML, you can use both files like this:

```

```

#### esbuild environment

[](#esbuild-environment)

Unlike `rollup.js`, `esbuild` needs no configuration file - it bundles a single source file (JavaScript or TypeScript) directly into one `iife` output file. In debug mode a source map is generated (`--sourcemap`); in production the output is minified (`--minify`) instead.

```
'js/app.js' => [ // target file
    'type' => DeployPhp\Assets::ESBUILD,
    'file' => 'ts/app.ts', // source file to bundle - JS or TS
    'tsconfig' => 'ts/tsconfig.json', // optional, path relative to the assets source directory, omit to run esbuild without a --tsconfig flag
],
```

The `tsconfig` is only needed when bundling TypeScript and you want esbuild to respect its `compilerOptions` (e.g. `target`, `jsx`, `paths`, decorators). Note that esbuild only reads `compilerOptions` from it to transform the code - it doesn't type-check, so running `tsc --noEmit` separately (e.g. in CI) is recommended if you want type errors caught.

Create `assets/ts/tsconfig.json`:

```
{
    "compilerOptions": {
        "target": "ES2020",
        "module": "ESNext",
        "moduleResolution": "bundler",
        "strict": true,
        "skipLibCheck": true,
        "noEmit": true
    }
}
```

#### Example

[](#example)

In `deploy/assets.php`:

```
use Forrest79\DeployPhp;

require __DIR__ . '/vendor/autoload.php';

return (new DeployPhp\Assets(
    __DIR__ . '/../temp',
    __DIR__ . '/assets',
    [
        'images' => DeployPhp\Assets::COPY,
        'fonts' => DeployPhp\Assets::COPY,
        'css/styles' => [ // target directory, main.css will be created here
            'type' => DeployPhp\Assets::SASS,
            'file' => 'css/main.sass',
        ],
        'css/many-styles' => [ // target directory, main.css and print.css will be created here
            'type' => DeployPhp\Assets::SASS,
            'files' => [
                'css/main.sass',
                'css/print.sass',
            ]
        ],
        'js/jquery.min.js' => DeployPhp\Assets::COPY,
        'js/jquery.min.map' => [
            'type' => DeployPhp\Assets::COPY,
            'env' => DeployPhp\Assets::DEBUG,
        ],
        'js/scripts.{format}.js' => [ // target file - will be compiled for more formats
            'type' => DeployPhp\Assets::ROLLUP,
            'file' => 'js/index.js',
        ],
        'js/app.js' => [ // target file
            'type' => DeployPhp\Assets::ESBUILD,
            'file' => 'ts/app.ts',
            'tsconfig' => 'ts/tsconfig.json', // optional, omit to run esbuild without a tsconfig
        ],
    ],
    static function (string $configFile): ?string {
        if (!file_exists($configFile)) {
            return null;
        }

        $data = Neon\Neon::decode(file_get_contents($configFile));
        if (!isset($data['assets']['hash'])) {
            return null;
        }

        return $data['assets']['hash'];
    },
    static function (string $configFile, string $hash): void {
        file_put_contents($configFile, "assets:\n\t\thash: $hash\n");
    },
    [
        __DIR__ . '/../package-lock.json', // outside the assets source directory, but its content should still trigger a rebuild
    ],
    [
        __DIR__ . '/assets/node_modules', // inside the assets source directory (needed there e.g. for esbuild/rollup imports), but shouldn't trigger a rebuild on every "npm install"
    ],
    ((($localConfig = @include __DIR__ . '/assets.local.php') === false) ? [] : $localConfig)
);
```

Neon file with hash has this structure:

```
parameters:
    assets:
        hash: c11a678785091b7f1334c24a4123ee75 # md5 hash (32 characters)
```

In `deploy/assets.local.php` you can define local source assets directory, if you're using some virtual server, where the paths are different from your host paths. This directory will be used for JS and CSS map files to property open source files in the browser console:

```
return [
	'localSourceDirectory' => 'P:/app/assets',
];
```

Or you need to specify here your local server bin directory, if differ from `/usr/bin:/bin` (directory, where is `node` binary):

```
return [
	'systemBinPath' => '/opt/usr/bin:/opt/bin',
];
```

In `app/bootstrap.php`:

```
$configurator->addConfig(__DIR__ . '/config/config.neon');

if (PHP_SAPI !== 'cli') {
    $assetsConfigFile = __DIR__ . '/config/config.assets.neon';
    $configurator->addConfig($assetsConfigFile);
    if ($configurator->isDebugMode()) {
        $assets = @include __DIR__ . '/../assets/assets.php'; // intentionally @ - file may not exists - good when production with production assets is running in debug mode (production preferable doesn't have assets source)
        if ($assets !== false) {
            $assets->buildDebug($assetsConfigFile, __DIR__ . '/../../www/assets');
        }
    }
}

$configurator->addConfig(__DIR__ . '/config/config.local.neon');

$container = $configurator->createContainer();
```

In debug mode, hash is calculated from every assets file timestamp - creating hash is fast (if you change file or add/remove some file, hash is changed and assets are automatically rebuilt before the request is performed).

In Nette, you need to define you own Assets extension, that will read hash from `assets.hash` and with some sort of service, you can use it in your application. For example, like this:

```
// Service to use in application

namespace App\Assets;

class Assets
{
    /** @var string */
    private $hash;

    public function __construct(string $hash)
    {
        $this->hash = $hash;
    }

    public function getHash(): string
    {
        return $this->hash;
    }

}

// Extension that uses neon structure with hash (just register this as extension in config.neon)

namespace App\Assets\DI;

use App\Assets;
use Nette\DI\CompilerExtension;

class Extension extends CompilerExtension
{
    private $defaults = [
        'hash' => null,
    ];

    public function loadConfiguration()
    {
        $builder = $this->getContainerBuilder();

        $config = $this->validateConfig($this->defaults, $this->config);

        $builder->addDefinition($this->prefix('assets'))
            ->setFactory(Assets\Assets::class, [$config['hash']]);
    }

}
```

In your application, you can use hash as query parameter `styles.css?hash` or as virtual path in web server, example for nginx, load assets at path `assets/hash/styles.css`:

```
location /assets/ {
    expires 7d;
    rewrite ^/assets/[a-z0-9]+/(.+)$ /assets/$1 break;
}

```

When building application:

```
/** @var DeployPhp\Assets $assets */
$assets = require __DIR__ . '/assets.php';
$assets->buildProduction($releaseBuildDirectory . '/app/config/config.assets.neon', $releaseBuildDirectory . '/www/assets')
```

Hash is computed from all file content, so hash is changed only when some file content is changed or the same file is added/remove (creating hash is slow).

### Build and deploy

[](#build-and-deploy)

Contains just some helper methods to checkout from GIT, copy files via SFTP, and run commands via SSH. For documentation look at example.

#### Example

[](#example-1)

```
use Forrest79\DeployPhp;

require __DIR__ . '/../vendor/autoload.php';

//define('SSH_PRIVATE_KEY', 'define-this-in-deploy.local.php');
//define('SSH_AGENT_SOCK', 'define-this-in-deploy.local.php');
//define('DEPLOY_TEMP_DIRECTORY', 'define-this-in-deploy.local.php'); // if you want to change from default repository temp - on VirtualBox is recommended /tmp/... or some local (not shared) directory

require __DIR__ . '/deploy.local.php';

class Deploy extends DeployPhp\Deploy
{
    /** @var array */
    protected array $config = [
        'vps' => [
            'gitBranch' => 'master',
            'ssh' => [
                'server' => 'ssh.site.com',
                'directory' => '/var/www/site.com',
                'username' => 'forrest79',
                'private_key' => 'C:\\Certificates\\certificate',
                'passphrase' => null, // is completed dynamically - if needed (agent is tried at first), can be also callback call when password is needed
                'ssh_agent' => SSH_AGENT_SOCK, // true - try to read from env variable, string - socket file
            ],
            'deployScript' => 'https://www.site.com/deploy.php',
        ]
    ];

    private string $releasesDirectory;

    private string $releaseName;

    private string $releasePackage;

    private string $releaseBuildPackage;

    protected function setup()
    {
        $this->releasesDirectory = defined('DEPLOY_TEMP_DIRECTORY')
            ? DEPLOY_TEMP_DIRECTORY
            : __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'temp' . DIRECTORY_SEPARATOR . 'deploy';

        $this->releaseName = 'release-' . date('Ymd-His') . '-' . uniqid();
        $this->releasePackage = $this->releaseName . '.tar.gz';
        $this->releaseBuildPackage = $this->releasesDirectory . DIRECTORY_SEPARATOR . $this->releasePackage;
    }

    public function run()
    {
        /** when password is get at the begin of the script (the old way)
        if (!$this->validatePrivateKey()) {
            $this->error('Bad passphrase for private key or bad private key.');
        }
        */

        $this->log('=> Creating build...');
        $this->createBuild();
        $this->log('   ...DONE');

        $this->log('=> Deploying build...');
        $this->deployBuild();
        $this->log('   ...DONE');

        $this->log('=> Cleaning up local files');
        $this->delete($this->releaseDirectory);
        $this->log('   ...DONE');
    }

    private function createBuild()
    {
        $releaseBuildDirectory = $this->releasesDirectory . DIRECTORY_SEPARATOR . $this->releaseName;

        $this->log('     -> checkout from GIT', false);
        if (!$this->gitCheckout(__DIR__ . DIRECTORY_SEPARATOR . '..', $releaseBuildDirectory, $this->environment['gitBranch'])) {
            $this->error(' ...cant\'t checkout from GIT');
        }
        $this->log(' ...OK');

        $this->log('     -> building assets', false);

        $assets = require __DIR__ . '/assets.php';
        assert($assets instanceof DeployPhp\Assets);

        $assets
            ->setup($releaseBuildDirectory . '/app/config/config.assets.neon', $releaseBuildDirectory . '/app/assets', $releaseBuildDirectory . '/www/assets')
            ->buildProduction();
        $this->log(' ...OK');

        $this->log('     -> preparing package', false);
        $this->delete($releaseBuildDirectory . '/app/assets');
        $this->delete($releaseBuildDirectory . '/conf');
        $this->delete($releaseBuildDirectory . '/data');
        $this->delete($releaseBuildDirectory . '/db');
        $this->delete($releaseBuildDirectory . '/deploy');
        $this->delete($releaseBuildDirectory . '/download');
        $this->delete($releaseBuildDirectory . '/logs');
        $this->delete($releaseBuildDirectory . '/temp');
        $this->delete($releaseBuildDirectory . '/.gitignore');
        $this->delete($releaseBuildDirectory . '/composer.json');
        $this->delete($releaseBuildDirectory . '/composer.lock');
        $this->log(' ...OK');

        $this->log('     -> compressing package', false);
        $this->gzip($this->releasesDirectory, $this->releaseName, $this->releaseBuildPackage);
        $this->log(' ...OK');
    }

    private function deployBuild()
    {
        $remoteReleaseDirectory = $this->environment['ssh']['directory'] . '/releases';
        $remoteReleaseBudilDirectory = $remoteReleaseDirectory . '/' . $this->releaseName;
        $this->log('     -> uploading build package', false);
        if (!$this->sftpPut($this->releaseBuildPackage, $remoteReleaseDirectory)) {
            $this->error(' ...an error occurred while uploading build package');
        }
        $this->log(' ...OK');

        $this->log('     -> extracting build package, creating temp, symlinks and removing build package', false);
        if (!$this->ssh('cd ' . $remoteReleaseDirectory . ' && tar xfz ' . $this->releasePackage . ' && rm ' . $this->releasePackage . ' && mkdir ' . $remoteReleaseBudilDirectory . '/temp && ln -s ' . $this->environment['ssh']['directory'] . '/logs ' . $remoteReleaseBudilDirectory . '/logs && ln -s ' . $this->environment['ssh']['directory'] . '/data ' . $remoteReleaseBudilDirectory . '/www/data && ln -s ' . $this->environment['ssh']['directory'] . '/config/config.local.neon ' . $remoteReleaseBudilDirectory . '/app/config/config.local.neon')) {
            $this->error(' ...an error occurred while extracting build package, creating temp and symlinks');
        }
        $this->log(' ...OK');

        $this->log('     -> releasing build (replace link to current)', false);
        if (!$this->ssh('ln -sfn ' . $remoteReleaseBudilDirectory . ' ' . $this->environment['ssh']['directory'] . '/current_new && mv -Tf ' . $this->environment['ssh']['directory'] . '/current_new ' . $this->environment['ssh']['directory'] . '/current')) {
            $this->error(' - an error occurred while releasing build');
        }
        $this->log(' ...OK');

        $this->log('     -> running after deploy script', false);
        if (!$this->httpRequest($this->environment['deployScript'] . '?' . $this->releaseName , 'OK')) {
            $this->error(' ...an error occurred while running deploy script');
        }
        $this->log(' ...OK');

        $keepBuilds = 5;
        $this->log('     -> cleaning up old builds', false);
        if (!$this->ssh('ls ' . $remoteReleaseDirectory . '/* -1td | tail -n +' . ($keepBuilds + 1) . ' | grep -v ' . $this->releaseName . ' | xargs rm -rf')) {
            $this->error(' ...an error occurred while cleaning old build');
        }
        $this->log(' ...OK');
    }

}

/**
 * RUN FROM COMMAND LINE *******************************************************
 * *****************************************************************************
 */

if ($argc == 1) {
    echo "Usage: php deploy.php  [git-branch]";
    exit(1);
}

/** when password is get at the begin of the script
echo 'Enter SSH key password: ';

try {
    $passphrase = Deploy::getHiddenResponse();
    echo PHP_EOL;
} catch (RuntimeException $e) {
    echo '[Can\'t get hidden response, password will be visible]: ';
    $passphrase = Deploy::getResponse();
}

$additionalOptions = ['ssh' => ['passphrase' => $passphrase]];
*/

$additionalOptions = [
    'ssh' => [
        'passphrase' => static function (Deploy $deploy, string $privateKeyFile): string {
            $passphrase = null;

            do {
                echo $passphrase === null ? PHP_EOL . '          > Enter SSH key password: ' : '  > Bad password, enter again: ';

                try {
                    $passphrase = Deploy::getHiddenResponse();
                    echo PHP_EOL . '        ';
                } catch (RuntimeException) {
                    echo '[Can\'t get hidden response, password will be visible]: ';
                    $passphrase = Deploy::getResponse();
                }
            } while (!$deploy->validatePrivateKey($privateKeyFile, $passphrase));

            return $passphrase;
        },
    ],
];

if ($argc > 2) {
    $additionalOptions['gitBranch'] = $argv[2];
}

try {
    (new Deploy($argv[1], $additionalOptions))->run();
} catch (Exception $e) {
    echo $e->getMessage() . "\n";
    exit(1);
}
```

### Composer monorepo

[](#composer-monorepo)

IF you're using monorepo for you applications, you need simple tool to prepare correct `composer.lock`. This is the simple one for a repository that meets these requirements:

- one shared global vendor directory with all libraries
- more applications with local vendors that on local development using the shared one and are installed on production

> Be careful, using this tool update is always performed on the global composer! The next step is copy global composer to the local one and update is also performed here. After this is local vendor cleaned.

> Just for hint, differences between global and locals composer.json are shown. This may not be a mistake.

> This tool can handle global composer in neon format - you need [Nette\\Neon](https://github.com/nette/neon) in your vendor and [Composer neon plugin](https://github.com/forrest79/composer-yaml-neon-plugin)

#### Example:

[](#example-2)

```
/apps/appA/composer.json
/apps/appA/composer.lock
/apps/appA/vendor (autoload.php -> /vendor/autoload.php)
/apps/appB/composer.json
/apps/appB/composer.lock
/apps/appB/vendor (autoload.php -> /vendor/autoload.php)
/vendor/autoload.php
/vendor/[with all packages]
composer.json
composer.lock
prepare-monocomposer (source is below)

```

- global vendor is committed in repository and to prepare production build, global vendor is copied to the local one and `composer install` is executed in the app directory, so only needed packages are kept here

```
#!/usr/bin/env php
