vitamin2 / laravel-sync
A git-like artisan command to easily sync files and folders between environments
Requires
- php: ^8.3
- illuminate/support: ^12.0||^13.0
- laravel/prompts: ^0.3
Requires (Dev)
- driftingly/rector-laravel: ^2.2
- larastan/larastan: ^3.9
- laravel/pao: ^1.0
- laravel/pint: ^1.29
- orchestra/testbench: ^10.0||^11.0
- pestphp/pest: ^4.6
- pestphp/pest-plugin-laravel: ^4.1
- pestphp/pest-plugin-type-coverage: ^4.0
- phpstan/extension-installer: ^1.4
- rector/rector: ^2.5.7
Suggests
None
Provides
None
Conflicts
- phpstan/phpstan: >=2.2.6
Replaces
None
This package is auto-updated.
Last update: 2026-09-02 07:49:41 UTC
README
Laravel Sync
A git-like artisan command to easily sync files and folders between environments via rsync.
Think git push/git pull, but for files that don't belong in your repository — user uploads,
.env files, database dumps, anything you'd otherwise copy by hand with scp or an FTP client.
You define named "recipes" of paths and named "remotes" (servers or other local folders) once in
config, then push or pull them with one command, from anywhere.
Contents
- Concepts
- Quick Start
- Requirements
- Installation
- Configuration
- Usage
- Examples
- Changelog
- Contributing
- Security Vulnerabilities
- Credits
- License
Concepts
A few terms used throughout this README:
- Remote — one environment you sync with: a real server (has
user+host) or another local folder on the same machine (omit both — nosshinvolved). - Recipe — a named group of paths, relative to your project's root, that belong together (e.g.
'assets' => ['storage/app/assets/']). - Push — copy files from your local machine to a remote.
- Pull — copy files from a remote to your local machine. This overwrites local files — see Safety Net below.
- Root — the absolute path to a remote's project folder; every recipe path is resolved relative to it.
- Dry run (
--dry) — connects and reports exactly what would change, without writing anything.
Safety Net
Because a pull can overwrite local files, Laravel Sync leans on a few guardrails so a mistake is hard to make and easy to undo:
- Confirmation prompt before any real (non-dry) sync when running interactively.
--dryto preview exactly what would happen first, with no risk.read_onlyremotes refuse apush, so you can't accidentally overwrite production.--backupsnapshots the local files a pull is about to overwrite, so you can restore them withsync:backups-restoreif the pull wasn't what you wanted.- Concurrency lock stops two syncs against the same remote from racing each other.
sync:doctorchecks rsync and SSH are actually ready before you rely on any of the above.
Quick Start
composer require vitamin2/laravel-sync
php artisan vendor:publish --tag="laravel-sync-config"
Add a remote and a recipe to the published config/sync.php:
'remotes' => [ 'production' => [ 'user' => 'forge', 'host' => '104.26.3.113', 'root' => '/home/forge/example.com', ], ], 'recipes' => [ 'assets' => ['storage/app/assets/', 'storage/app/img/'], ],
Then check the connection and try it out:
# Confirm SSH access and that "root" exists on the remote php artisan sync:test-connection production # Dry run: connects and reports what would change, without writing anything php artisan sync pull production assets --dry # Pull it for real php artisan sync pull production assets
Every config key is explained below in Configuration, and every command and option in Usage — or jump straight to Examples for more copy-pasteable commands.
Requirements
rsyncon both your local machine and the remote host — runrsync --versionon each to check; most macOS/Linux machines and servers already have it.- A working
sshsetup between your local machine and the remote host (agent or~/.ssh/config) — ifssh user@hostalready logs you in without a password prompt, you're set.
Not syncing with a real server (just two folders on the same machine)? Skip both — see Remotes.
Installation
You can install the package via Composer:
composer require vitamin2/laravel-sync
Publish the config file:
php artisan vendor:publish --tag="laravel-sync-config"
This publishes config/sync.php:
return [ 'remotes' => [ // 'production' => [ // 'user' => 'forge', // 'host' => '104.26.3.113', // 'port' => 22, // 'root' => '/home/forge/example.com', // 'read_only' => env('SYNC_PRODUCTION_READ_ONLY', true), // ], ], 'recipes' => [ // 'assets' => ['storage/app/assets/', 'storage/app/img/'], ], 'options' => [ '--archive', ], ];
Configuration
Remotes
Each remote needs a root path. Add user and host to sync with an actual server over ssh; omit both to
treat the remote as a plain local path (handy for syncing between two projects on the same machine, no ssh
involved).
| Key | Description |
|---|---|
user |
The username to log in to the host. Omit together with host for a local remote. |
host |
The IP address or hostname of the server. Omit together with user for a local remote. |
port |
The SSH port to use. Defaults to 22. |
root |
The absolute path to the project's root folder. |
read_only |
When true, blocks push to this remote. Defaults to false. |
Once an SSH remote (user/host) is configured, run php artisan sync:test-connection <remote> to confirm
access and that root exists before you rely on it for a real sync — see
Testing a Connection. A local remote reports success immediately without checking
root, since there's no connection to test.
Recipes
Recipes name a set of paths, relative to your project's root, that belong together:
'recipes' => [ 'assets' => ['storage/app/assets/', 'storage/app/img/'], 'env' => ['.env'], ],
Options
The default rsync options, used whenever --option isn't passed on the command line:
'options' => [ '--archive', ],
Excludes
Optional, keyed by recipe name. An array of rsync --exclude patterns applied only when
that recipe is synced, on top of the options above:
'excludes' => [ 'assets' => ['*.log', 'node_modules/'], ],
If a path appears in more than one recipe you sync together, its command gets the union of
every one of those recipes' excludes for that path. Excludes only apply to the sync itself —
a --backup pass still copies the full path, since it's a fixed, independent copy (see
Backup Directory), not shaped by any rsync option.
Excludes From
Optional, keyed by recipe name. An array of file paths (relative to your project's root), each
containing rsync exclude patterns (one per line), applied via rsync's own --exclude-from when
that recipe is synced — useful for a long exclude list you'd rather keep in its own file than
inline in excludes:
'excludes_from' => [ 'assets' => ['.rsync-excludes'], ],
Combines with excludes rather than replacing it. A configured file that doesn't exist fails
fast with a friendly error before anything is synced — checked only for the recipe(s) actually
being synced, not every recipe defined in your config.
A relative path is resolved from your project's root; an absolute one is used as written, so
storage_path('app/.rsync-excludes') works as you'd expect. The file need not sit inside the
project either — a .. segment or a symlink pointing out both resolve as written, so a sibling
checkout or a shared storage can hold the list.
Backup Directory
Relative to your project's root. When --backup is passed on a real pull, the local files
of the selected recipes are copied here, into a timestamped folder, before the pull runs:
'backup_dir' => '.sync-backups',
Each backed-up pull adds another timestamped folder; nothing prunes old ones automatically. Add
backup_dir to your .gitignore and run php artisan sync:backups-clean to clean it out periodically —
see Cleaning Up Backups.
Usage
php artisan sync {push|pull} {remote} {recipe...} [options]
| Command | Description |
|---|---|
sync |
Run the sync. |
sync:list |
Preview the origin, target, options, and port in a table, without syncing. |
sync:commands |
Print the rsync commands that would be run, without syncing. |
sync:backups-clean |
Delete backup folders created by a backed-up pull. |
sync:backups-restore |
Restore a backup folder's contents back onto the project root. |
sync:test-connection |
Test the SSH connection (and root path) for a remote. |
sync:doctor |
Check that rsync and SSH access are ready for a real sync. |
Options shared across most commands:
| Option | Description |
|---|---|
-O, --option=* |
Override the default rsync options. Repeatable. |
-D, --dry |
Perform a dry run, with real-time output. On sync:backups-clean, preview which backups would be deleted. On sync:backups-restore, preview what would be restored. |
-A, --all |
Sync every configured recipe. On sync:backups-clean, delete every backup. On sync:doctor, check every configured remote. |
-B, --backup |
Back up local files to backup_dir before a real pull. |
-v |
Show real-time output while syncing (progress, stats, ...). |
sync:backups-clean and sync:backups-restore each take a couple more options of their own — see
Cleaning Up Backups and Restoring Backups below.
Any argument you omit is prompted for interactively (operation, remote, recipes, and rsync options), unless
you pass --no-interaction, in which case a missing required value fails fast with a clear error instead of
prompting — and any real (non-dry) sync runs immediately without a confirmation prompt.
Use --dry for a dry run, not --option=--dry-run — only --dry skips the confirmation prompt, forces
real-time output, and reports it as a dry run instead of a completed sync.
--backup only applies to a real (non-dry) pull — a pull is the only operation that overwrites your local
files, so a push (or a dry run) ignores it. Before the pull runs, the local files of the selected recipes
are copied into a timestamped folder under backup_dir (e.g. .sync-backups/2026-07-24_134530/), using a
fixed --archive --relative copy independent of your chosen rsync options. If you don't pass --backup and
you're pulling interactively, you're asked whether to back up before you're asked which rsync options to use.
Cleaning Up Backups
sync:backups-clean deletes timestamped folders under backup_dir, leaving backup_dir itself (and
anything in it that isn't a timestamped backup folder) untouched. Run it without options to pick backups
from an interactive list (with size and age), or pass --all to select every one. Add --dry to preview
what would be deleted without deleting anything, and -F/--force to skip the confirmation prompt.
Running it with --no-interaction and without --all or a retention option fails fast with a friendly error
instead of deleting anything — there's no picker to fall back to, and deleting every backup by default would
be surprising. The confirmation prompt only appears when running interactively, so --no-interaction --all
(e.g. in a cron job) deletes immediately without needing --force.
Pass -K/--keep=N and/or --older-than=N (days) to select backups by retention criteria instead of picking or
--all — the only selection method that works non-interactively without --all, making it the one to use in
a cron job. --keep=N deletes everything but the N newest; --older-than=N deletes anything older than N
days; combined, --older-than picks the candidates and --keep still protects the N newest among them, even
if they're also older than the cutoff. Combining either with --all is rejected, since --all already
selects every backup. --older-than is capped at 36500 days (~100 years) — comfortably beyond any real
retention window, and refused outright rather than risking day-arithmetic overflow silently deleting
everything instead of nothing.
Restoring Backups
sync:backups-restore copies a backup folder's contents back onto your project root, undoing a backed-up
pull. Pass the backup's name ({backup} argument) or omit it to pick one from an interactive list; add
--dry to preview what would be restored (with real-time output) without touching anything, and -F/--force
to skip the confirmation prompt.
By default it only overwrites files the backup actually captured — it doesn't delete anything created since
the backup was taken. Add -M/--mirror for a 1:1 restore instead, adding rsync's --delete so the project root
ends up exactly matching the backup, with anything not in it removed. Run php artisan sync:backups-clean
afterwards if you also want to remove the backup you just restored.
Testing a Connection
sync:test-connection authenticates to a remote over SSH and confirms its root path exists, without
syncing anything — useful for catching a misconfigured remote (or a broken SSH setup) before a real sync fails
partway through with an opaque rsync error. A local remote (no user/host) reports success immediately,
without opening any connection.
Concurrency
Two sync runs against the same remote can't overlap — the second fails immediately rather than racing the
first. Nothing to configure; the lock always releases when the run ends.
Checking Readiness
sync:doctor checks that a real sync would actually work, reporting the results in a table: that rsync is
installed locally, and — for each remote checked — that the SSH connection and root path succeed (the same
check sync:test-connection runs) and that rsync is installed on the remote too. Pass a remote name, or
omit it to pick one interactively; pass --all to check every configured remote at once. A local remote (no
user/host) skips the SSH checks entirely, since there's no connection to test.
Each SSH round trip is bounded to 10 seconds, reported as a distinct timeout failure rather than hanging. Exits with a failure code if any check fails, so it's safe to use as a pre-deploy or CI gate.
Examples
# Pull the "assets" recipe from "staging" php artisan sync pull staging assets # Push "assets" to "production" with custom rsync options php artisan sync push production assets --option=-avh --option=--delete # Preview a pull as a dry run, with real-time output php artisan sync pull staging assets --dry # Back up local "assets" files before pulling php artisan sync pull staging assets --backup # Sync every recipe php artisan sync push production --all # Preview what would run, without syncing php artisan sync:list pull staging assets php artisan sync:commands pull staging assets # Check the SSH connection and root path for a remote before syncing php artisan sync:test-connection staging # Check that rsync and SSH access are ready for a real sync php artisan sync:doctor staging # Check every configured remote at once, e.g. as a CI/pre-deploy gate php artisan sync:doctor --all --no-interaction # Fully interactive php artisan sync # Pick which backups to delete from an interactive list php artisan sync:backups-clean # Delete every backup without a confirmation prompt php artisan sync:backups-clean --all --force # Preview which backups --all would delete php artisan sync:backups-clean --all --dry # Cron-safe cleanup: keep the 5 newest backups, delete anything else older than 30 days php artisan sync:backups-clean --keep=5 --older-than=30 --no-interaction # Restore a specific backup, skipping the confirmation prompt php artisan sync:backups-restore 2026-07-24_134530 --force # Preview what a restore would change, with real-time output php artisan sync:backups-restore 2026-07-24_134530 --dry # Restore a backup 1:1, deleting anything the backup doesn't have php artisan sync:backups-restore 2026-07-24_134530 --mirror --force
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Thank you for considering contributing to Laravel Sync! Please review our contributing guide to get started.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
Laravel Sync is open-sourced software licensed under the MIT license.