symplify/vendor-patches
Symplify Vendor Patches generates vendor patches for your Composer packages with a single command. Copy a vendor file with a .old suffix, edit the original, then run generate to create patch files and auto-configure cweagans/composer-patches.
symplify/vendor-patches is a patch generator, not a patch manager. You modify a file inside vendor/, keep the pristine copy next to it with an .old suffix, run one command, and it writes a unified diff into patches/ plus the extra.patches entry that cweagans/composer-patches replays on every composer install. All runtime behavior after generation belongs to cweagans, not to this package.
Install both, and be deliberate about the dependency type:
composer require --dev symplify/vendor-patches
composer require cweagans/composer-patches # NOT --dev if you deploy with --no-dev
That second line is the one Laravel teams get wrong. Forge, Envoyer, Vapor, and Laravel Cloud overwhelmingly run composer install --no-dev, so a require-dev patch plugin never loads and your patch silently never applies. Treat cweagans as a production dependency.
Then allow the plugin (Composer 2.2+ prompts for this, but pin it explicitly so CI never stalls on a prompt):
"config": {
"allow-plugins": {
"cweagans/composer-patches": true
}
}
Where to look first:
vendor/bin/vendor-patches — confirm the binary landed.patches/ — created on first generate; one .patch per modified file, named from the original path, so names are unique and stable.extra.patches in the root composer.json — written for you. This is the entire configuration surface; there is no vendor-patches.yaml to hunt for.First use case. Suppose a reporting package throws a TypeError on PHP 8.4 because a method signature wasn't updated, and the maintainer hasn't tagged a release:
# 1. Preserve the original
cp vendor/acme/report/src/Formatter.php vendor/acme/report/src/Formatter.php.old
# 2. Edit vendor/acme/report/src/Formatter.php in place.
# The .old file is never autoloaded, so you can run the app and verify.
# 3. Generate
vendor/bin/vendor-patches generate
# 4. cweagans/composer-patches v2 only
composer patches-relock
composer patches-repatch
# 5. Refresh the lock hash (composer.json changed)
composer update --lock
# 6. Verify from a clean slate
rm -rf vendor && composer install --verbose
You should end up with something like:
"extra": {
"patches": {
"acme/report": [
"patches/acme_report_src_formatter.php.patch"
]
}
}
Commit patches/, composer.json, and composer.lock in the same commit. From that point a plain composer install reproduces the fix on every machine and every deploy target.
Fix now, PR later — and keep the window short. The healthy loop is: reproduce the bug, edit vendor, generate the patch, open the upstream PR the same day, and reference the PR URL in the commit that adds the patch. When the release lands, composer update acme/report, delete the .patch file and its extra.patches entry, then composer update --lock. Patches age badly: the longer one lives, the more likely it collides with an unrelated upgrade of the same package.
Batch edits per file, then generate once. Patches are keyed to file path, so two separate edit sessions against the same file produce one patch and the second run overwrites the first. If you're touching several files in a package, edit them all, run generate once, and review the whole set before committing. For multi-package changes, generate walks all of vendor/ and emits everything in a single pass.
Move the mapping out of composer.json if it grows. --patches-file=patches.json writes the patch list into a dedicated artifact, which is far easier to review than a growing block inside extra. Pair it with --patches-output=patches-composer if patches/ is already claimed by a JS toolchain or a legacy cweagans layout.
Private and path repositories. When the installed directory name doesn't match name in the package's own composer.json (common with path repos and internal forks), pass --resolve-from-directory. Without it the tool emits an extra.patches key that Composer never matches against an installed package — generation succeeds, nothing applies, and the failure is invisible.
CI and deployment wiring. Nothing extra is needed in CI beyond allow-plugins above, but two things bite:
vendor/ between jobs, bust the cache whenever patches/ changes, or cache only the Composer download cache and let install re-run.composer install rather than shipping a prebuilt vendor/ artifact. On Vapor and Laravel Cloud this is guaranteed; on a hand-rolled Envoyer hook it may not be.After deploy, restart anything holding code in memory: php artisan queue:restart, php artisan octane:reload, and re-run php artisan config:cache. Patches that touch a service provider are invisible to already-running workers until you do.
Patch review is code review. A .patch file is a readable diff. PRs adding or removing patches should show it inline, and the reviewer's question is "does this change upstream behavior, or just guard it?" That's a strong argument for committing patches/ rather than applying edits inside a deploy hook where nobody ever sees them.
Prefer extension over patching when a seam exists. Before writing a patch, check whether the package gives you one: php artisan vendor:publish for config/views/migrations, View::addNamespace() or Blade component overrides, Lang::addLines() / lang/vendor/, container bind/extend, Macroable, Model::resolveRelationUsing(), or a listener subscribed to the package's events. Also weigh forking via a type: vcs repository — patching wins for a one-line fix, forking wins for anything structural, because a fork upgrades cleanly while a patch does not.
generate diffs the current vendor tree against .old — including edits you forgot about. vendor/ is gitignored, so git status tells you nothing. Only create .old copies for files you actually intend to patch. If you're unsure the tree is clean (debugging session, leftover experiments), rm -rf vendor && composer install before regenerating.
Patch drift is the primary failure mode. After upgrading the patched package, composer install fails with Hunk #N FAILED or patch does not apply. Diagnose with composer install -vvv; cweagans surfaces the exact hunk. The fix is mechanical: reinstall clean, reapply your edit by hand against the new version, re-copy .old, regenerate. Budget for this after every dependency bump touching a patched package — it's the hidden cost of the approach.
composer.json changed, so the content hash changed. Skipping composer update --lock produces the "lock file is not up to date" warning and hard-fails on stricter setups or with --strict. Easy to forget in a busy PR; cheap to fix.
cweagans v2 changed the contract twice. You must run composer patches-relock (regenerates patches.lock.json, which needs committing) and composer patches-repatch after generate. Separately, migrating from v1: any existing patch starting with --- /dev/null must be rewritten to --- <file-path>, otherwise it fails with an error that reads like a path-stripping problem but isn't.
Path prefixes and -p1 stripping. Generated patches use paths relative to the package root, and cweagans applies them with the package directory as cwd. Don't hand-edit paths to ../src/... or absolute paths unless you know exactly how your config strips prefixes. For hand-authored patches that add a file, use the v2 --- <file-path> header rather than the v1 /dev/null convention.
macOS hangs mid-apply. Install GNU patch (brew install gpatch) and put /opt/homebrew/opt/gpatch/libexec/gnubin ahead of the BSD patch on PATH. This is a BSD-vs-GNU incompatibility, not a bad patch — don't start rewriting hunks because of it.
Keep the patch surface tiny and documented. One patch per bug, each with a comment or commit body naming the upstream issue. When you add a patch, add a line to a short patches/README or the PR description explaining why and when to remove it. Un-commented patches become permanent, unowned vendor edits that nobody dares touch.
How can I help you explore Laravel packages today?