Configuration
FTP Deployer reads configuration from config/ftp-deployer.php. The default config defines one production profile and uses environment variables for secrets and host-specific paths.
Top-level shape
return [
'default' => env('FTP_DEPLOYER_PROFILE', 'production'),
'profiles' => [
'production' => [
'ftp' => [/* FTP settings */],
'paths' => [/* remote path model */],
'exclusions' => [/* local paths excluded from release */],
'hooks' => [/* local shell hooks */],
'remote_commands' => [/* remote Artisan commands */],
'archive' => [/* ZIP archive deploy mode */],
'frontend' => [/* frontend build detection */],
],
],
];
The current Artisan command accepts the profile name directly:
php artisan ftp-deploy production
php artisan ftp-deploy staging
If omitted, the command default is production.
FTP settings
'ftp' => [
'host' => env('FTP_DEPLOYER_HOST'),
'username' => env('FTP_DEPLOYER_USERNAME'),
'password' => env('FTP_DEPLOYER_PASSWORD'),
'port' => (int) env('FTP_DEPLOYER_PORT', 21),
'ssl' => (bool) env('FTP_DEPLOYER_SSL', false),
'passive' => (bool) env('FTP_DEPLOYER_PASSIVE', true),
'timeout' => (int) env('FTP_DEPLOYER_TIMEOUT', 90),
],
| Key | Required | Default | Description |
|---|---|---|---|
host |
yes | null |
FTP server host name. |
username |
yes | null |
FTP username. |
password |
yes | null |
FTP password. |
port |
no | 21 |
FTP/FTPS port. |
ssl |
no | false |
Uses ftp_ssl_connect() when true. |
passive |
no | true |
Enables passive mode after login. |
timeout |
no | 90 |
FTP connection timeout in seconds. |
[!NOTE] This package uses PHP FTP functions. It does not use SSH or SFTP in the current implementation.
Path settings
'paths' => [
'mode' => env('FTP_DEPLOYER_MODE', 'simple'),
'ftp_root' => env('FTP_DEPLOYER_FTP_ROOT', '/'),
'app_root' => env('FTP_DEPLOYER_APP_ROOT', 'app'),
'public_root' => env('FTP_DEPLOYER_PUBLIC_ROOT', 'app/public'),
'app_url' => env('FTP_DEPLOYER_APP_URL'),
'filesystem_root' => env('FTP_DEPLOYER_FILESYSTEM_ROOT'),
'release_root' => env('FTP_DEPLOYER_RELEASE_ROOT', '../app/releases'),
'shared_root' => env('FTP_DEPLOYER_SHARED_ROOT', '../app/shared'),
'current_path' => env('FTP_DEPLOYER_CURRENT_PATH', '../app/current'),
],
| Key | Required | Default | Description |
|---|---|---|---|
mode |
no | simple |
simple or versioned. |
ftp_root |
yes | / |
FTP-login-relative base path prepended to remote FTP paths. |
app_root |
yes | app |
Laravel root for simple mode. Contains artisan. |
public_root |
yes | app/public |
Web-accessible public root. Runner is uploaded here. |
app_url |
yes | null |
Public URL used to call the temporary runner. |
filesystem_root |
archive mode | null |
Absolute PHP filesystem path matching ftp_root. Used by the runner to find uploaded ZIPs and extract them. |
release_root |
versioned | ../app/releases |
Directory containing versioned releases, relative to ftp_root. |
shared_root |
versioned | ../app/shared |
Directory containing shared .env and storage, relative to ftp_root. |
current_path |
versioned | ../app/current |
Compatibility pointer written after deploy, relative to ftp_root. Current bootloader hardcodes the release id and does not read this file per request. |
Simple mode path mapping
Simple mode deploys directly over one live app tree.
Local path:
routes/web.php
app/Http/Controllers/HomeController.php
public/build/assets/app.js
Remote path with default simple config:
{ftp_root}/app/routes/web.php
{ftp_root}/app/app/Http/Controllers/HomeController.php
{ftp_root}/app/public/build/assets/app.js
public/* local files map into public_root; all other local files map into app_root.
Recommended remote layout:
{ftp_root}/app/.env
{ftp_root}/app/storage/
{ftp_root}/app/vendor/
{ftp_root}/app/public/index.php
Use simple mode when you want the smallest remote layout and can accept live overwrite deploys.
Versioned mode path mapping
Versioned mode deploys each app build to a new release directory and updates managed public_root/index.php to point at that release.
Local path:
routes/web.php
app/Http/Controllers/HomeController.php
public/build/assets/app.js
Remote path with default versioned config and release id 20260702123456:
{ftp_root}/app/releases/20260702123456/routes/web.php
{ftp_root}/app/releases/20260702123456/app/Http/Controllers/HomeController.php
{ftp_root}/public_html/build/assets/app.js
Recommended remote layout:
{ftp_root}/public_html/index.php managed bootloader, overwritten each deploy
{ftp_root}/app/releases/20260702123456/ current app release
{ftp_root}/app/releases/20260702123456/vendor/ production vendor extracted from hash-named ZIP
{ftp_root}/app/shared/.env persistent env
{ftp_root}/app/shared/storage/ persistent Laravel storage
{ftp_root}/app/current compatibility pointer
In versioned mode, PHP app files go under the new release directory. Public assets stay in stable public_root because the web server serves static files directly. The bootloader hardcodes the active release id, so normal requests do not read current_path.
Filesystem root for archive mode
Archive mode uploads ZIP files over FTP, then asks the public runner to extract them through PHP. FTP paths are not PHP filesystem paths, so configure the matching absolute filesystem root:
FTP_DEPLOYER_FTP_ROOT=/public_html/laravelapp.inja.online
FTP_DEPLOYER_FILESYSTEM_ROOT=/home/<cpanel-user>/public_html/laravelapp.inja.online
The deployer derives paths like:
FTP upload: {ftp_root}/.ftp-deployer/archives/app-{random}.zip
PHP reads: {filesystem_root}/.ftp-deployer/archives/app-{random}.zip
PHP extracts: {filesystem_root}/{app_root}
Archive mode
'archive' => [
'enabled' => (bool) env('FTP_DEPLOYER_ARCHIVE_ENABLED', true),
],
Archive mode is enabled by default.
Behavior:
- Reuses local production Composer vendor cache at
/tmp/ftp-deployer-vendor-{composer_json}-{composer_lock}; runscomposer install --no-dev --prefer-dist --optimize-autoloader --no-interaction --no-scriptsonly on cache miss. - Builds and uploads an app ZIP every deploy.
- Names vendor ZIPs from Composer input hashes:
vendor-{composer_json}-{composer_lock}.zip. - Uploads vendor ZIP only when that hash archive does not already exist remotely.
- In simple mode, extracts vendor into
{app_root}/vendorwhen Composer inputs change. - In versioned mode, extracts the reused hash-named vendor ZIP into
{release_root}/{release_id}/vendor. - In versioned mode, does not move optimized vendor to shared storage because Composer optimized autoload contains release-relative app class paths.
- Excludes
vendor/, deployer temp files, dev config (package.json,phpunit.xml,phpstan.neon,pint.json,vite.config.*), runtime junk, anddatabase/*.sqlite*from the app ZIP. - Uploads deny
.htaccessfiles before ZIPs. - Uses random app ZIP filenames under
.ftp-deployer/archives/; vendor ZIP names are deterministic. - Extracts ZIPs through the authenticated runner before remote commands.
- Deletes stale manifest-managed files after extraction and before saving the manifest.
- Deletes temporary app ZIPs after extraction on a best-effort basis. Vendor archives are retained for reuse.
Disable archive mode to use recursive FTP uploads:
FTP_DEPLOYER_ARCHIVE_ENABLED=false
Requirements:
- Local PHP
zipextension. - Remote PHP
zipextension. - Correct
FTP_DEPLOYER_FILESYSTEM_ROOT.
Exclusions
Default exclusions:
'exclusions' => [
'.git/',
'.github/',
'.idea/',
'.vscode/',
'node_modules/',
'tests/',
'storage/app/public/',
'storage/framework/cache/',
'storage/framework/sessions/',
'storage/framework/testing/',
'storage/framework/views/',
'storage/logs/',
'bootstrap/cache/*.php',
'.env',
'.env.*',
'.ftp-deployer/',
],
The release builder copies your working tree to a temporary directory and skips matching paths.
Pattern behavior:
- Directory-style entries ending in
/match everything under that directory. - Exact paths match that file or directory.
fnmatch()patterns are supported, e.g.bootstrap/cache/*.php.
Keep these excluded unless you know what you are doing:
.envand.env.*: prevents local secrets from overwriting remote secrets.storage/framework/*: prevents local cache/session/view files from replacing production runtime state.storage/logs/: prevents local logs from replacing production logs.storage/app/public/: prevents accidental deletion or overwrite of user uploads..ftp-deployer/: prevents deploy state from being copied from local source.
Local hooks
'hooks' => [
'before_deploy' => [],
'after_deploy' => [],
],
Hooks are local shell commands executed from the current working directory.
Example:
'hooks' => [
'before_deploy' => [
'composer test',
'npm run build',
],
'after_deploy' => [
'php artisan deploy:notify production',
],
],
Behavior:
before_deployruns before release build and FTP connection.- If any
before_deployhook exits non-zero, deployment stops. after_deployruns only after upload, runner execution, version activation, manifest save, and cleanup have succeeded.- If an
after_deployhook fails, the command returns failure, but remote deploy work has already happened.
Remote commands
'remote_commands' => [
'migrate --force',
'optimize:clear',
'optimize',
['command' => 'storage:link', 'ignore_failures' => true],
],
Remote commands are Artisan commands executed by the temporary HTTP runner on the target host.
Supported entry shapes:
'migrate --force'
Becomes:
{"cmd":"migrate --force","fail_on_error":true}
Structured command:
['command' => 'storage:link', 'ignore_failures' => true]
Becomes:
{"cmd":"storage:link","fail_on_error":false}
Behavior:
- Required command fails (
fail_on_error = true) → runner stops and returns failure. - Tolerated command fails (
fail_on_error = false) → runner logs failure and continues. - Commands should be Artisan command names and flags, not arbitrary shell commands.
Frontend configuration
'frontend' => [
'enabled' => true,
'auto_detect' => true,
'builds' => [],
'warn_if_inputs_changed_without_output_change' => true,
],
The deployer does not run npm, yarn, pnpm, or bun. Build assets before deploy using local hooks or CI.
Auto detection
When builds is empty and auto_detect is true:
- If
package.jsonis missing, frontend logic is skipped silently. - If
public/build/manifest.jsonexists, Vite buildappis detected with outputpublic/build/. - If
public/build/.vite/manifest.jsonexists, Vite 5+ buildappis detected with outputpublic/build/. - If
public/mix-manifest.jsonexists, Laravel Mix buildappis detected with outputspublic/css/andpublic/js/. - If
package.jsonexists but no manifest is found, the deployer warns and continues without frontend upload.
Explicit builds
Use explicit builds for multiple frontends or custom output paths:
'frontend' => [
'enabled' => true,
'auto_detect' => true,
'builds' => [
'app' => [
'type' => 'vite',
'manifest' => 'public/build/manifest.json',
'outputs' => ['public/build/'],
],
'admin' => [
'type' => 'vite',
'manifest' => 'public/admin-build/manifest.json',
'outputs' => ['public/admin-build/'],
],
],
],
When explicit builds are present, auto-detection does not add extra builds.
Stale-build warning inputs
The deployer records hashes for:
package.jsonpackage-lock.jsonyarn.lockpnpm-lock.yamlbun.lockbun.lockbwebpack.mix.jsvite.config.jsvite.config.ts
If frontend inputs change but build manifest hashes do not, the deployer warns that build output may be stale.
Common frontend choices
No frontend:
'frontend' => [
'enabled' => false,
'auto_detect' => false,
'builds' => [],
'warn_if_inputs_changed_without_output_change' => false,
],
React/Vite with Bun in a local hook:
'hooks' => [
'before_deploy' => [
'bun install --frozen-lockfile',
'bun run build',
],
'after_deploy' => [],
],
Custom remote Artisan setup after upload/extraction:
'remote_commands' => [
'migrate --force',
'app:setup:cache',
'optimize:clear',
'optimize',
['command' => 'storage:link', 'ignore_failures' => true],
],
More copy-paste cases: Cookbook & Examples.
Migrating from Simple to Versioned Mode
If you are upgrading an existing profile from simple mode to versioned mode, you can automate the configuration setup locally using the interactive migration command:
php artisan ftp-deploy:migrate production --write
This command will:
- Ask you for the three required versioned paths (release root, shared root, and current path pointer).
- Validate the target structure (checking that
public_rootis not nested inapp_rootwith reversed nesting). - Automatically append the required variables to your local
.envfile if you provide the--writeflag and confirm.
Server-Side Migration Actions
The migration command updates your local environment settings, but does not modify files on your remote FTP server. You must perform these remote moves before running the first versioned deploy:
- Create shared root: Create
{ftp_root}/{shared_root}if it does not exist. - Move
.envfile: Move your remote.envfrom{ftp_root}/{app_root}/.envto{ftp_root}/{shared_root}/.env. - Move
storagefolder: Move your remotestorage/directory to{ftp_root}/{shared_root}/storage/. - Check Permissions: Ensure PHP can write to
{shared_root}/storageand each new release’sbootstrap/cache. - Deploy: Run
php artisan ftp-deploy productionto create{release_root}/{release_id}, upload the managed bootloader, reuse the hash-named vendor ZIP upload, and extract vendor into that release. - Verify bootloader: Confirm
{public_root}/index.phpcontains the deployed release id and boots the new release.
Do not manually edit the managed bootloader after versioned deploys. The deployer overwrites it each time with the new hardcoded release id.
Complete .env example
FTP_DEPLOYER_PROFILE=production
FTP_DEPLOYER_HOST=ftp.example.com
FTP_DEPLOYER_USERNAME=deploy@example.com
FTP_DEPLOYER_PASSWORD=change-me
FTP_DEPLOYER_PORT=21
FTP_DEPLOYER_SSL=false
FTP_DEPLOYER_PASSIVE=true
FTP_DEPLOYER_TIMEOUT=90
FTP_DEPLOYER_MODE=simple
FTP_DEPLOYER_FTP_ROOT=/
FTP_DEPLOYER_APP_ROOT=app
FTP_DEPLOYER_PUBLIC_ROOT=app/public
FTP_DEPLOYER_APP_URL=https://laravelapp.inja.online
FTP_DEPLOYER_FILESYSTEM_ROOT=/home/your-user/public_html/laravelapp.inja.online
FTP_DEPLOYER_ARCHIVE_ENABLED=true
Versioned additions:
FTP_DEPLOYER_MODE=versioned
FTP_DEPLOYER_PUBLIC_ROOT=public_html
FTP_DEPLOYER_RELEASE_ROOT=../app/releases
FTP_DEPLOYER_SHARED_ROOT=../app/shared
FTP_DEPLOYER_CURRENT_PATH=../app/current