aaronfrancis/airdrop
Airdrop is a Laravel package for transferring files between your local machine and remote servers. Simple Artisan commands let you upload and download files, making it easy to sync assets, logs, or backups without manual FTP or SSH setup.
Airdrop's job is narrow and worth understanding before you install it: it hashes a configurable set of source files, compares that hash against the hash from the last successful build, and runs your asset build command (npm ci && npm run build, or whatever you configure) only if something relevant changed. If nothing changed, it short-circuits and your deploy saves 30–90 seconds.
The package moved. As of v2.0.0 it lives under the Wilber Group organization: the Composer name is wilbergroup/airdrop and the PHP namespace is WilberGroup\Airdrop. aaronfrancis/airdrop is the old coordinate and will not receive updates. It's a drop-in replacement functionally — same config keys, same command — but it is a breaking change for anything that references the old namespace (see the upgrade notes under Gotchas).
Minimal setup:
composer require wilbergroup/airdrop --dev
php artisan vendor:publish --tag="airdrop-config"
If you're upgrading an existing install, swap the dependency in one step so Composer resolves cleanly:
composer remove aaronfrancis/airdrop
composer require wilbergroup/airdrop --dev
php artisan config:clear # published config + provider references are cached
Then open config/airdrop.php first — this is the only file that really matters. You'll find (roughly) three knobs:
resources/js, resources/css, package.json, package-lock.json, vite.config.js, and Tailwind/PostCSS configs.Your first use case is the obvious one: replace the raw build step in your deploy hook with the artisan command.
# before
npm ci && npm run build
# after
php artisan airdrop:build
Verify it works locally by running it twice — the first run should build, the second should report "no changes, skipping." If the second run still builds, your hash paths are wrong (usually you're missing a file that the build actually reads). Note for upgraders: the first run on v2.0.0 will rebuild even with no source changes, because the trigger class names feed into the hash — that's expected, once, and then it resumes skipping.
Where to look next, in order: src/Console/ for the command signatures and available flags (--force, verbosity), then the service provider to see what's bound. The README is short; the source is ~a few hundred lines and readable in one sitting.
Wrap, don't replace, your real build. Airdrop is a conditional gate, not a bundler. Keep the underlying command honest and idempotent, because Airdrop will invoke it verbatim:
// config/airdrop.php
'command' => 'npm ci && npm run build',
If you need a second step (copying assets, running vite build --ssr), that belongs in a package.json script so the hash gate stays a single command.
Deploy hook integration (Forge / Envoyer / Deployer). The key constraint is that the state file must survive between deploys — it cannot live inside the release directory that gets swapped out. Point it at a shared path or a stable cache location:
'state_file' => storage_path('app/airdrop.hash'),
…where storage is your shared/symlinked directory. On Forge, that's typically already the case if storage is symlinked; on Envoyer you may need to move it into the shared folder explicitly.
Rolling out the v2 rename across a fleet. Because the hash file is shared across deploys, every server/pipeline needs to land on v2 together the first time or you'll get ping-pong rebuilds: a v1 host writes a v1-flavoured hash, the v2 host sees a mismatch, rebuilds, writes a v2 hash, and the next v1 host rebuilds again. Harmless but noisy. Either upgrade all hosts in one release, or bump the state file path (e.g. airdrop-v2.hash) on cutover so the old hash is simply abandoned rather than fought over.
Docker multi-stage builds. This is where Airdrop pays for itself most. Copy package*.json and the source directories, mount the hash state via a BuildKit cache mount, and let the gate skip the npm run build layer on backend-only changes:
RUN --mount=type=cache,target=/app/storage/app \
php artisan airdrop:build
Expect one extra full build on the release that bumps the vendor name — the changed source class names change the hash, and the layer cache key moves with composer.lock anyway.
CI caching in GitHub Actions. Pair the hash file with a actions/cache entry keyed on hashFiles('package-lock.json'). Don't try to cache node_modules across major Node versions — you'll get silent native-binding failures that are painful to trace back to the cache. Add composer.lock to the key so dependency bumps (including this one) self-invalidate.
Force a rebuild when you know better than the hash. Any environment-driven build (staging vs. production, different API base URLs) needs an explicit escape hatch. Either pass --force in that pipeline or delete the state file before the step. The VITE_* variables baked into the bundle at build time are not part of a default hash set — treat that as a real configuration responsibility, not a bug.
Chain with your other cache-warming steps. Airdrop runs early in the deploy; let it finish first, then run php artisan config:cache, route:cache, view:cache. Running those before the asset build works but wastes time if the build fails and you have to roll back — and remember that config:cache caches the resolved config, so a stale cache from the old provider namespace can bite you right after the upgrade.
Separate state per branch/environment. If two deploys of different branches share a server and a state file, they'll clobber each other's hashes and you'll get spurious rebuilds (harmless) or, worse, a skip when you wanted a build (not harmless). If your infra shares one path, namespace the state file by environment.
The rename is the whole breaking change — audit your own references. Composer will handle the dependency swap, but your code won't: published config files, any use AaronFrancis\Airdrop\... imports, custom service-provider bindings, config-cache warmers, and CI scripts that call the old vendor path all need updating. If you only did the "drop-in" swap and got a class-not-found at deploy time, you're looking at a stale bootstrap/cache/config.php or a leftover use statement. php artisan config:clear first, then grep for aaronfrancis / AaronFrancis.
Don't end up with both packages installed. A composer require without the matching composer remove can leave aaronfrancis/airdrop in vendor/ if it's still a transitive requirement of something else. Two service providers registering the same command name is a confusing failure mode. Check composer show | grep airdrop after the swap.
The first v2 deploy rebuilds — plan for it. Trigger class names are part of the hash, so the hash necessarily changes when the package changes. Your first deploy on v2 rebuilds assets regardless of frontend changes; if you run a build-time budget or a "skipped build" assertion in CI, it will trip once. Don't "fix" it by forcing a state-file reset — it's already doing the right thing.
The hash is only as good as the globs. The single most common failure is a file that affects the build but isn't hashed — tailwind.config.js, postcss.config.js, a .babelrc, a fonts directory, an SVG sprite, a tsconfig.json. Symptom: you change Tailwind's theme, deploy, and the old CSS ships. When in doubt, hash generously; the cost of a false-positive rebuild is seconds, the cost of a false-negative is a broken production site.
Environment variables are invisible to the hash. Vite inlines import.meta.env.VITE_* at build time. If VITE_API_URL differs between staging and production, your hash says "nothing changed" and you deploy staging's bundle to production. Add your env files to the hash list, or force rebuild per environment. Do not skip this.
Install with --dev is a trap on production deploys. If Airdrop lives in require-dev (as the docs suggest) and your deploy runs composer install --no-dev before the asset step, php artisan airdrop:build simply doesn't exist. Order your deploy script so Composer runs with dev dependencies for the build, then prunes — or keep Airdrop in require and accept the small footprint. Whichever you choose, update the new vendor name in any allowlist/ignore rules you maintain.
Never write the hash before the build succeeds. Confirm the ordering in the source if you're customizing the command. A hash written on a failed vite build means every subsequent deploy skips the build and ships whatever half-written assets happen to be in the release dir. If you extend the package, preserve this invariant.
npm ci vs npm install in the gate. npm ci deletes node_modules and reinstalls — which is correct and safe, but it means the "skipped" path has to skip the whole command, not just the build half. Verify that a skip genuinely touches nothing, otherwise you save 40 seconds on the build and spend it back on the install.
Native modules and reused node_modules. Any node_modules caching strategy (yours or implicit) will eventually hand you a Sharp/esbuild binary compiled for the wrong platform. If you see "invalid ELF header" or esbuild platform mismatches after a cache hit, clear the cache and key it on runner.os as well as the lockfile.
Path separators and globbing. On Windows dev machines the glob layer may behave differently than Linux CI for patterns like resources/**/*.js. If you develop on Windows and deploy on Linux, test the hash paths inside the same OS you deploy on — or use forward slashes consistently and avoid deep nested globs.
Debugging a "why did it skip?" moment. Run the build command with increased verbosity to see the files considered and the computed hash. If the package doesn't dump the list, temporarily set the build command to something that echoes the paths — it's the fastest way to spot a missing entry. Deleting the state file and rebuilding is the canonical reset.
Watch the maintenance signal, not just the features. v2.0.0 is a transfer + Laravel 13 / PHP 8.5 support release: no new configuration surface, no new flags, no behavioural changes beyond the hash churn. The practical takeaway is that the new coordinates are what you should track in your dependency tooling (Dependabot/Renovate rules pointed at aaronfrancis/airdrop will silently stop seeing releases). Also confirm your PHP/Laravel versions against the v2 matrix before bumping — v1 pinned a narrower range, so if you're still on an older Laravel, upgrading the package is what unlocks 13, not the other way round.
Extension points. The config's command string, the hash path list, and the state file location are the intended seams. Anything beyond that — custom hashing algorithms, non-npm build systems — means reading the source and subclassing or replacing the command registration in your service provider (remembering to reference the new WilberGroup namespace). It's a small package, so this is viable, but don't expect a plugin architecture.
Commit or don't commit the state file? For a single-server app, committing it gives you reproducible "nothing changed" deploys. For multi-server or branch-per-deploy setups, it causes cross-talk. Default to keeping it out of version control and letting each environment maintain its own.
How can I help you explore Laravel packages today?