projectsend / v1-migration-tool
Imports a ProjectSend v1 (r2098-era) install — database and files — into ProjectSend v2. Installed on demand, removed when the migration is done.
Requires
- php: ^8.4
- illuminate/support: ^12.0
- inertiajs/inertia-laravel: ^2.0
Requires (Dev)
- larastan/larastan: ^3.10
- orchestra/testbench: ^10.0
- pestphp/pest: ^3.8
This package is auto-updated.
Last update: 2026-08-19 19:38:44 UTC
README
Imports a ProjectSend v1 install — its database and its uploaded files — into ProjectSend v2.
This is deliberately not part of v2. Migrating happens once, if ever, and the engine it needs — arbitrary database connections plus direct writes across the whole schema — is not something every install should carry idle. Install it when you want it, remove it when you're done.
Installing
composer require projectsend/v1-migration-tool
php artisan migrate # creates this package's two tables
What comes next depends on how ProjectSend itself was installed.
From source, or with Docker
npm run build # so this tool's screen enters the frontend bundle
Then open /system/migrate as a staff user with the Edit settings permission.
There is no sidebar link on purpose — a one-time tool doesn't earn a permanent slot in
the navigation.
From the release zip
Migrate from the command line. There is no /system/migrate screen on a zip install:
the zip ships its frontend already built, and without package.json there is no way to
build this tool's page into it. Opening the page there answers Unable to locate file in
Vite manifest.
You lose nothing by staying in the terminal. The commands do the same work, and they don't need a queue worker — the import runs right there until it's finished.
php artisan projectsend:migrate:preflight --v1-path=/var/www/projectsend-v1 php artisan projectsend:migrate:import --v1-path=/var/www/projectsend-v1 --files=hardlink php artisan projectsend:migrate:verify
For a bundle, swap --v1-path for --bundle=/path/to/bundle — copy the bundle
directory onto this server first, since there's no browser to upload it through.
Preflight changes nothing, so run it as often as you like. It will tell you if anything
has no v2 equivalent; --accept-skips on the import says you've read that list and want
to go ahead anyway.
--files decides what happens to the file bytes: hardlink when v1 and v2 share a
filesystem, copy (the default) otherwise. --help on any of the commands lists the
rest, including the --db-* overrides for when v1's config points somewhere the database
no longer is.
When the migration is done
Either way:
php artisan projectsend:migrate:reset --drop # optional; also drops this package's tables
composer remove projectsend/v1-migration-tool
--drop throws away the v1 → v2 id map. Keep it if you may ever want to redirect old
download.php?id=… links, which is the only thing that can resolve them.
What it needs from the host
| Requirement | Why |
|---|---|
| ProjectSend v2 with the schema this tool writes | Checked before anything runs — see src/Host/HostTables.php and src/Preflight/HostSchemaCheck.php. A mismatch is a hard stop, never a partial import |
| A fresh install — set up, not yet used | There are no merge semantics. See src/Preflight/FreshInstallCheck.php |
The staff route middleware alias and an edit_settings Gate |
Only for the /system/migrate screen — the host's IdentityServiceProvider registers both. The commands answer to whoever has a shell |
| A running queue worker | Also only for the screen: a real import outlives any web request, so the UI queues a job and polls a row. On the command line it simply runs |
Two ways in
Direct — v1 and v2 on the same machine. Point the tool at v1's database and its install directory; nothing is copied that doesn't have to be. This is the fast path: on one filesystem, file bytes are hardlinked rather than duplicated, so 400 GB migrates in seconds.
Bundle — v1 somewhere the v2 install cannot reach (a hosted ProjectSend, or simply a
different server). Run bin/projectsend-v1-export.php on the v1 box; it produces a
portable bundle you upload here, or point the commands at. The exporter is a single
dependency-free PHP file and never writes to the v1 install.
Direct mode can be switched off entirely with V1_MIGRATION_DIRECT_MODE=false — letting
an administrator type an arbitrary database host into a web form is fine on a box they
own and unwanted on a hosted deployment. It only closes the form; the commands are
unaffected, because anyone running them is already on the machine.
What it will not do
It reports these rather than guessing, and names every affected row:
- Encrypted files. v1 could encrypt files at rest; v2 has no equivalent, and the keys
are wrapped by a master key that exists only in v1's
sys.config.php. - Files on S3, GCS or Azure. v1's per-file external storage doesn't map onto v2's single-bucket setting.
- Hidden assignments — v2 has no hidden state.
- Two-factor secrets — encrypted with v1's key; those users re-enrol.
- v1 options with no v2 equivalent — v2 has ~43 settings where v1 had ~180.
It also cannot add an Action, Capability or Setting case: all three are closed
PHP enums the host casts database columns to, so a value invented here would throw on
read for every row afterwards — including rows this tool never touched. Anything v1
logged that v2 has no vocabulary for is dropped and counted, never approximated.
One thing changes for your users
v1 signs in with a username. v2 signs in with an email address. Every client's login therefore changes, and v1 did not require emails to be unique — preflight refuses to run until duplicates, blanks and invalid addresses are resolved, so nobody is silently merged or locked out. Tell your clients before you cut over; the run report gives you the list.
Development
composer install vendor/bin/pest
To exercise it against a real ProjectSend, scripts/sim.sh builds a disposable v2 with
this package installed and stages the seeded v1 fixtures into it:
scripts/sim.sh up # a v2 of its own on :8192 scripts/sim.sh fixture small # stage a v1 install scripts/sim.sh run small # preflight, import, verify scripts/sim.sh reset # undo it and try again
A development checkout cannot stand in for that instance: the tool imports into a fresh install only, and a checkout has content and shares its file storage with everything else you have been doing.
Stop instances you are not using (scripts/sim.sh down, or docker compose -p projectsend-<name> stop to keep their data). Each one runs its own MySQL, and
Docker's VM has a memory limit well below the host's — when it is reached the
kernel kills MySQL, which surfaces later as getaddrinfo for db failed on a page
that worked ten minutes earlier. Exited (137) in docker ps -a is the tell.
Tests run against a throwaway Testbench app with no host present, so the host tables are
replicated in tests/Support/HostSchema.php. That replica is a convenience, not the
contract — the contract is src/Host/HostTables.php, and it is verified against the real
database at preflight time.
License
Free software under the GNU General Public License v2, or (at your option) any later version — see LICENSE, the same terms as ProjectSend itself.
Released as MIT up to and including v1.0.0; GPL from v1.0.1 onward. Anyone who took an earlier release under MIT keeps those terms for that release.
Contributions require signing a CLA — individual or entity — for the reasons set out in ProjectSend's CONTRIBUTING.md.