aerni/sync
Sync Laravel files and databases between remote and local environments with artisan commands. Configure SSH connections, paths, and exclusions to pull fresh data and assets for development in one step.
aerni/laravel-sync gives you a git-style diff/confirm flow for moving files between a local disk and a remote disk (usually an SFTP disk pointed at staging or production). It is a developer convenience tool, not a deploy pipeline — treat it like rsync with a diff preview.
1. Install it where you run the commands (your machine / CI runner):
composer require aerni/laravel-sync --dev
You generally do not need it installed on the remote server; the package talks to the remote over a Laravel filesystem disk.
2. Publish the config:
php artisan vendor:publish --tag="sync-config"
3. Define the remote disk in config/filesystems.php. This is the step people get wrong most often. The disk root must be the app root you want to sync against, not the SFTP user's home directory:
'disks' => [
'local' => [
'driver' => 'local',
'root' => base_path(),
],
'production' => [
'driver' => 'sftp',
'host' => env('SYNC_HOST'),
'username' => env('SYNC_USER'),
'privateKey' => env('SYNC_KEY_PATH'),
'root' => '/var/www/my-app/current', // <= the app root
],
],
4. Point the package at both disks and declare what to sync in config/sync.php — typically local/remote disk names plus an include list (app, config, routes, resources, public, storage/app/public) and an exclude list (.git, vendor, node_modules, storage/framework, storage/logs).
5. First use case — pull production uploads so you can test against real data:
php artisan sync:pull --path=storage/app/public
You'll get a table of files marked added/modified/deleted, a confirmation prompt, and a progress bar. That diff-then-confirm loop is the whole mental model of the package.
Where to look first: run php artisan list sync to see the exact command signatures and flags for your installed version, then read config/sync.php top to bottom — every behaviour you care about (paths, excludes, direction, confirmation) is configured there.
Directional workflows, kept narrow. Use sync:pull for "give me the current state of the remote" and sync:push for "send this specific change up." Day to day, most pulls are scoped to one path:
# Refresh local media library from production
php artisan sync:pull --path=storage/app/public
# Ship a hotfixed Blade/notification template
php artisan sync:push --path=resources/views/mail --force
Scoping the path is the single most valuable habit: it keeps the diff small, keeps the confirmation review meaningful, and dramatically reduces the blast radius of an accidental deletion.
Commit before you sync. Because there's no merge, the safe pattern is: git commit → sync:pull → review the diff in git status → discard anything you didn't want. Git becomes your undo button, and the package becomes a transport.
Per-environment disk resolution. Keep config/sync.php committed and identical for the whole team, and drive the remote disk name from env:
// config/sync.php
'remote' => env('SYNC_REMOTE_DISK', 'production'),
Each developer sets their own target (staging vs. production, or their own sandbox) via .env, without touching shared config. Pair this with a .env.example entry so new hires see it immediately.
Pair it with a database pull for full environment parity. The natural composition is sync:pull for files + db:snapshot/mysqldump restore for data. Run them together in a Composer script so "get me production-like locally" is one command:
"scripts": {
"pull:prod": [
"@php artisan sync:pull --path=storage/app/public",
"@php artisan db:pull"
]
}
Use a read-only remote disk for pulls. Two disks pointing at the same host — one read-only (pull), one read-write (push) — is a cheap guardrail that prevents a mistyped direction from mutating production.
Deploy-time asset sync (the safe version). If you use the package for build artifacts (e.g. pushed public/build from CI), always pair --force (non-interactive) with a committed manifest, a dry-run step first, and a deploy user that owns nothing else:
php artisan sync:push --path=public/build --dry-run
php artisan sync:push --path=public/build --force
Extension points. When config isn't enough — dynamic remote roots per branch, or per-tenant folders — prefer custom Flysystem disk definitions resolved at runtime (config(['filesystems.disks.tenant_x' => [...]]) in a service provider) over forking the command. The package consumes plain disks, so anything you can express as a disk, you can sync.
Deletions are the real hazard. A sync is directional and destructive: if a file exists locally but not remotely, a push may delete it remotely (and vice versa). Before any push to production, run with --dry-run, read the deleted rows specifically, and remember the package has no concept of "protected" paths beyond your exclude list. Put .env, storage/logs, storage/framework, vendor, node_modules, and .git in the exclude list and never take them out.
It is not git. There is no three-way merge, no conflict detection, no history. Two developers syncing in opposite directions at the same time will silently clobber each other, last writer wins. Announce pushes on shared environments and prefer pull-only access for anyone who isn't deploying.
A wrong disk root writes to the wrong place, quietly. SFTP roots are often relative to the login user's home; /var/www/app vs ~/var/www/app can both succeed and put files somewhere unexpected. After the first sync, verify with a targeted ls on the remote — don't trust the success message.
File ownership and permissions after a push. Files written over SFTP belong to the SSH user, which is often not www-data. Anything pushed into storage/ or bootstrap/cache can then break the app with a 500 until you chown/chmod. Either run a post-sync fix-up script over SSH or restrict pushes to paths the web server only reads.
Stale manifests mean "everything changed." The package tracks state to compute diffs; if the manifest is missing (fresh clone, cleared cache, different machine) the first run will show a huge diff. Don't panic and don't push — do a --dry-run, and if the diff is entirely noise, rebuild the baseline rather than syncing it.
Case sensitivity and path separators. macOS/Windows local disks are case-insensitive; Linux remotes are not. App/Models and app/models will produce phantom add/delete pairs on every run. Same story for CRLF vs. LF on text files if hashing is content-based — normalise line endings in .gitattributes and keep the file set tight.
Symlinks and empty directories don't survive. Storage symlinks (public/storage) and intentionally empty dirs are commonly dropped or materialised as real files/dirs. Exclude them explicitly and re-run php artisan storage:link on the remote instead of syncing it.
Large trees and timeouts. SFTP is slow per-file; pulling storage/app with tens of thousands of uploads will crawl and can hit PHP max_execution_time or memory limits. Raise limits for the run, or sync in path-scoped batches. Never sync vendor — composer install on the target is faster and correct.
Config caching bites in the other direction. Locally, php artisan config:cache will freeze your sync.php changes and your env-driven remote disk; if the disk suddenly can't be resolved, config:clear is the first thing to try.
Secrets discipline. Pulling .env or a config/services.php from production into a laptop spreads credentials. If you must, pull into a scratch path (--path=storage/app/sync-tmp) and diff by hand — and keep the package out of production's composer.json so it can never be invoked server-side.
How can I help you explore Laravel packages today?