Core Concepts & Architecture
This page explains how the deployer works internally from command start to cleanup.
Execution pipeline
ReleaseBuilder
ReleaseBuilder creates a deployable release directory under your system temp directory:
/tmp/ftp-deployer-{random}/
It performs three steps:
- Copy the source working tree into the temp release.
- Run production Composer install inside the temp release.
- Remove runtime/cache junk before manifest generation.
Why a temp release exists
Deploying directly from your source tree is risky:
vendor/may include dev dependencies.- The package itself may be installed as a dev dependency.
- Local caches may contain paths from your machine.
- Local
.envmay contain secrets for a different environment. - Tests and local tooling should not be uploaded.
The temp release lets the deployer prepare production vendor/ without modifying your working tree.
Composer strategy
If composer.json exists, the deployer hashes composer.json and composer.lock and checks a local production vendor cache:
/tmp/ftp-deployer-vendor-{composer_json}-{composer_lock}/vendor
Cache hit:
Composer vendor cache hit: /tmp/ftp-deployer-vendor-{hashes}
The cached vendor/ is copied into the temp release and Composer is not run.
Cache miss:
composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction --no-scripts
After a successful install, the generated production vendor/ is copied into the local cache for future deploys with the same Composer hashes.
This means:
- Dependencies are installed locally/CI, not on the shared host.
- Composer only runs when Composer input hashes change or the local cache is missing.
- Dev dependencies are not shipped.
- Optimized autoload files are shipped.
Runtime cleanup
Before manifest generation, these paths are emptied but their root folders are kept:
storage/framework/cache
storage/framework/sessions
storage/framework/testing
storage/framework/views
storage/logs
bootstrap/cache
This prevents stale local runtime files from being uploaded.
Exclusion matching
Exclusions are applied while copying the source tree into the temp release.
Supported behavior:
path/matches everything underpath/.pathmatches exact path or anything under that path.*.php-style globs use PHPfnmatch().- Backslashes are normalized to forward slashes.
Examples:
| Pattern | Matches | Does not match |
|---|---|---|
node_modules/ |
node_modules/vite/bin/vite.js |
app/node_modules_helper.php |
.env |
.env |
.env.production |
.env.* |
.env.production |
.env |
bootstrap/cache/*.php |
bootstrap/cache/config.php |
bootstrap/cache/nested/config.php |
PathMapper
PathMapper converts local release-relative paths into FTP target paths.
It uses:
ftp_rootmodeapp_rootpublic_rootrelease_root- generated
release_id
Simple mode
Simple mode maps one deploy to one live app directory.
Rule:
public/* -> {ftp_root}/{public_root}/*
other -> {ftp_root}/{app_root}/*
Example:
public/index.php -> app/public/index.php
routes/web.php -> app/routes/web.php
With ftp_root = deployments/site-a:
public/index.php -> deployments/site-a/app/public/index.php
routes/web.php -> deployments/site-a/app/routes/web.php
Runtime model:
{app_root}/.env
{app_root}/storage/
{app_root}/vendor/
{public_root}/index.php
Simple mode is easiest to understand, but app ZIP extraction overwrites live files. If extraction fails midway, the live app can contain a mix of old and new files.
Versioned mode
Versioned mode maps each deploy to a fresh release directory.
Rule:
public/* -> {ftp_root}/{public_root}/*
other -> {ftp_root}/{release_root}/{release_id}/*
Example release id: 20260702123456
public/build/app.js -> public_html/build/app.js
routes/web.php -> app/releases/20260702123456/routes/web.php
Runtime model:
{public_root}/index.php managed bootloader, overwritten each deploy
{release_root}/{release_id}/ immutable app release
{release_root}/{release_id}/vendor/ production vendor extracted from hash-named ZIP
{shared_root}/.env persistent environment file
{shared_root}/storage/ persistent Laravel storage
{current_path} compatibility pointer, not used by bootloader per request
The public bootloader hardcodes the active release id when deployed:
$release = rtrim('app/releases', '/').'/20260702123456';
$shared = rtrim('app/shared', '/');
It does not read current_path on every request. This avoids one filesystem read per web request while keeping the active release explicit in index.php.
Remote manifest
The remote manifest lives at:
{ftp_root}/.ftp-deployer/manifest.json
It records deploy state so future deploys can upload only what changed.
Manifest shape
{
"schema_version": 1,
"deployed_at": "2026-07-02T12:34:56+00:00",
"deployed_from": {
"type": "git",
"ref": "main",
"sha": "abc123"
},
"paths": {},
"inputs": {
"composer": {
"composer_json": "md5",
"composer_lock": "md5"
},
"frontend": {
"package_json": "md5",
"lock_files": {},
"config_files": {}
}
},
"groups": {
"app": {
"strategy": "file-diff",
"count": 120,
"hash": "aggregate-md5"
},
"vendor": {
"strategy": "composer-inputs",
"paths": ["vendor/"]
},
"frontend": {
"strategy": "build-manifest",
"builds": {
"app": {
"type": "vite",
"manifest_file": "public/build/manifest.json",
"manifest_hash": "md5",
"output_paths": ["public/build/"],
"assets": {
"public/build/assets/app.js": "app/public/build/assets/app.js"
}
}
}
}
},
"files": {
"routes/web.php": {
"remote_path": "app/routes/web.php",
"hash": "md5",
"size": 512,
"mtime": 1751459696
}
}
}
Normal app files
files contains normal app files only. It does not include:
vendor/*- frontend build output paths
- excluded paths
- the manifest itself
Diff rules:
- Upload when file is new.
- Upload when file hash changed.
- Upload when target
remote_pathchanged, which is critical for versioned releases. - Skip when hash and remote path match.
- Delete remote managed files that existed in previous manifest but no longer exist locally.
Vendor group
vendor/ is not diffed file-by-file. Instead:
- Hash
composer.json. - Hash
composer.lock. - Build deterministic archive name:
vendor-{composer_json}-{composer_lock}.zip. - If hashes match an existing remote archive, reuse it instead of uploading vendor again.
- In simple archive mode, extract vendor to
{app_root}/vendorwhen Composer inputs changed. - In versioned archive mode, reuse the remote hash-named vendor ZIP upload, but extract it into each release’s
vendor/. - In recursive FTP mode, upload
vendor/as a directory when Composer inputs changed, or always in versioned mode.
This avoids scanning thousands of vendor files into the manifest and avoids re-uploading/re-extracting vendor when Composer inputs have not changed.
Frontend group
Frontend build output is tracked by build manifest hash and asset list.
- If build manifest hash changed, upload configured output directories.
- Previously managed frontend assets that disappeared locally are deleted only if they are inside configured output paths.
- Files outside configured frontend outputs are never deleted as frontend cleanup.
Archive upload behavior
Archive mode is the default upload strategy. It reduces FTP round-trips by uploading ZIP artifacts instead of thousands of individual files.
temp release
├── app-{random}.zip every deploy, excludes vendor/dev/runtime junk/SQLite DBs
└── vendor-{composer_json}-{composer_lock}.zip deterministic vendor archive, reused when hashes match
FTP upload
└── {ftp_root}/.ftp-deployer/archives/{archive}.zip
HTTP runner
├── extract app archive using {filesystem_root}
├── extract vendor archive into app root or release vendor
├── delete stale manifest-managed files
├── save manifest
└── run configured commands
Why filesystem_root exists:
FTP path: /public_html/laravelapp.inja.online/.ftp-deployer/archives/app.zip
PHP path: /home/user/public_html/laravelapp.inja.online/.ftp-deployer/archives/app.zip
The runner runs in PHP and needs the PHP filesystem path, not the FTP path.
Failure semantics:
- Extraction fails → deploy fails, manifest is not saved, stale deletes are not run.
- Extraction succeeds → stale remote files from the previous manifest are deleted.
- Manifest saves before remote commands.
- Command failure after extraction leaves the manifest representing extracted remote files.
Simple mode extracts over live files, so partial extraction can leave mixed files if the host fails mid-extract. Versioned mode is safer because extraction targets a new release directory and only the managed public bootloader points traffic at the new release after upload/extraction succeeds.
Archive behavior by mode
| Behavior | Simple mode | Versioned mode |
|---|---|---|
| App archive destination | {app_root} |
{release_root}/{release_id} |
| Public files | {public_root} |
{public_root} with managed bootloader |
| Vendor archive name | vendor-{composer_json}-{composer_lock}.zip |
same |
| Vendor extract destination | {app_root}/vendor |
{release_root}/{release_id}/vendor |
| Vendor reuse | skip upload if archive exists and hashes match | skip upload if archive exists; still extract into fresh release |
Release vendor path |
real directory | real directory |
.env |
{app_root}/.env |
{shared_root}/.env |
| Storage | {app_root}/storage |
{shared_root}/storage |
| Request routing | normal public/index.php |
managed public/index.php hardcodes release id |
FTP upload behavior
Recursive FTP mode is used when archive mode is disabled. FTP operations are intentionally small:
- Connect using
ftp_connect()orftp_ssl_connect(). - Login.
- Enable passive mode when configured.
- Recursively create parent directories before uploading.
- Upload to temporary remote name.
- Delete old final path.
- Rename temp file to final path.
- Throw if upload or rename fails.
Temporary upload names look like:
remote/path/file.php.tmp-a1b2c3d4
This reduces the chance of leaving a half-written final file.
Frontend detection
The deployer does not run build tools. It only detects and uploads existing build output.
Auto-detection order
frontend.enabled = false→ skip.- No
package.json→ skip silently. public/build/manifest.json→ Vite.public/build/.vite/manifest.json→ Vite 5+.public/mix-manifest.json→ Laravel Mix.package.jsonexists but no manifest → warn and continue.
Explicit builds
Explicit builds are exact. If frontend.builds contains entries, auto-detection does not add more.
Use this for:
- admin bundles
- Filament panels
- custom Vite output dirs
- multiple frontend applications
HTTP runner
The runner is generated from stubs/install.php.stub for each deploy.
Generated values:
- random filename:
install-{16 hex chars}.php - random token: 32 bytes encoded as 64 hex chars
- app/root mode placeholders
The runner is uploaded under public_root, then called at:
{app_url}/{runner_filename}
Request payload
Command phase payload:
{
"token": "random-deploy-token",
"commands": [
{"cmd": "migrate --force", "fail_on_error": true},
{"cmd": "storage:link", "fail_on_error": false}
]
}
Archive extraction phase payload:
{
"token": "random-deploy-token",
"extract": [
{"archive": "/home/user/public_html/site/.ftp-deployer/archives/app-random.zip", "destination": "/home/user/public_html/site/app/releases/20260702123456"},
{"archive": "/home/user/public_html/site/.ftp-deployer/archives/vendor-a-b.zip", "destination": "/home/user/public_html/site/app/releases/20260702123456/vendor", "skip_if_exists": "/home/user/public_html/site/app/releases/20260702123456/vendor/autoload.php"}
]
}
skip_if_exists prevents duplicate extraction into the same destination if a runner call is retried. Versioned releases still get their own vendor/ directory because optimized Composer autoload paths are release-relative.
No .env values or secrets are sent.
Runner checks
Before commands run, the runner checks:
- token matches
- required PHP extensions loaded
artisanexistsvendor/autoload.phpexistsbootstrap/app.phpexists.envexists.envcontains non-emptyAPP_KEY- storage path is writable
bootstrap/cacheis writable
If any check fails, it returns JSON failure and runs no commands.
Cache invalidation
The runner deletes:
bootstrap/cache/*.php
before booting Laravel or running fallback commands. This prevents stale cached config/routes/events from breaking the new release.
Command execution modes
Simple mode:
- If
exec()exists, run commands through shell:
cd APP_ROOT && php artisan {command} --no-interaction 2>&1
Fallback / versioned mode:
- Boot Laravel in the web process.
- Run
Artisan::call(). - Inject shared
.envand storage paths for versioned mode.
JSON response
Success:
{
"ok": true,
"logs": [
{"command": "migrate --force", "ok": true, "output": "..."}
]
}
Failure:
{
"ok": false,
"logs": [
{"command": "migrate --force", "ok": false, "output": "..."}
]
}
Outputs are sanitized for common secret patterns like APP_KEY, DB_PASSWORD, PASSWORD, SECRET, and TOKEN.
Versioned bootloader
Versioned mode manages public index.php from stubs/versioned-index.php.stub.
The bootloader:
- Hardcodes active release path:
{release_root}/{release_id}. - Sets
APP_ENV_FILEto{shared_root}/.env. - Sets
LARAVEL_STORAGE_PATHto{shared_root}/storage. - Requires active release
bootstrap/app.php. - Calls
useStoragePath()when available. - Handles the HTTP request with Laravel HTTP kernel.
The bootloader is uploaded on every versioned deploy because the release id changes. It does not read current_path on each request.
Activation and manifest save order
The deployer saves the new manifest only after:
- Files upload successfully.
- Removed managed files are deleted.
- Runner uploads successfully.
- Runner returns successful JSON.
- Versioned compatibility current pointer is updated, if enabled.
- New manifest is saved.
- Runner deletion is attempted in final cleanup, best effort.
If upload or runner execution fails, the new manifest is not saved. This keeps remote state from claiming a failed deploy succeeded. Runner cleanup is still attempted after failure.
Failure model
| Failure | Result |
|---|---|
| Missing profile | Command fails before FTP connection. |
| Missing FTP credential | Command fails before FTP connection. |
| Composer install fails locally | Command fails before FTP connection. |
| FTP upload fails | Command fails, runner may never upload. |
| FTP rename fails | Command fails, temp file deletion is attempted. |
| Runner upload succeeds but HTTP call fails | Runner deletion is attempted in cleanup. |
| Runner returns failed requirement check | Command fails, manifest is not saved. |
| Required remote command fails | Runner stops, command fails, manifest is not saved. |
| Ignored remote command fails | Runner logs failure and continues. |
| Version activation fails | Command fails, manifest is not saved. |
| Local cleanup fails | Could leave temp release locally; remote state may already be deployed. |
Security model
Security relies on multiple layers:
- Runner filename has random entropy.
- Runner token is random and required for every request.
- Runner accepts JSON payload only and does not use query-string secrets.
- Runner sends no
.envsecrets. - Runner validates
.envexists instead of writing it. - Runner output is sanitized.
- Runner is deleted over FTP after success or failure.
- Deployment state excludes
.envand runtime paths by default.
[!WARNING] Use HTTPS for
app_url. The token travels in the HTTP request body. Plain HTTP exposes it to network observers.