Originally published Ocrober 2021. Substantially rewritten in May 2026 to cover PHP 8+ tooling, modern patch generation, and lessons learned from five more years of Magento work.
For most of my career building Magento stores, I avoided patches entirely. If something was broken in a third-party library, I’d write a new module, override the class through di.xml, and move on. That was the “proper” Magento way, and for a long time it served me well.
Over the last few years, though, my position has softened. Patches have a legitimate place in a Magento project — not as a replacement for modules, but as a complement to them. There are situations where a single-line patch saves hours of module scaffolding and ships a fix the same afternoon. There are also situations where reaching for a patch is the wrong instinct entirely. The trick is knowing the difference.
This is an update to a post I wrote in 2021. The fundamentals haven’t changed, but the tooling has, and so has my thinking. PHP 8 is now the floor for new Magento projects, cweagans/composer-patches has matured into the de facto standard, and there are better workflows for generating patches than the manual git diff dance I described back then. I want to cover all of it here.
What a patch actually is
A patch is a text file describing the difference between two versions of one or more files. The format dates back to Larry Wall’s patch utility from 1985, and the unified diff format you’ll see in every modern patch file is the same one Git uses internally. When you run composer install, the patch gets applied to a file in vendor/ after the package is downloaded but before Composer finalizes the install.
The crucial point: patches modify code that is otherwise outside your version control. Your vendor/ directory is in .gitignore. The library code lives on Packagist, not in your repo. Without a patch, any change you make to vendor/ evaporates the next time someone runs composer install. With a patch, the change is reproducible, version-controlled, and travels with your project.
When patches make sense
I reach for a patch when all of these are true:
- The fix is small. We’re talking a handful of lines, a corrected return type, a missing null check, a swapped constant. If I find myself patching twenty lines across four files, something is wrong with my approach.
- The fix is genuinely a fix. It corrects broken behavior in the library. It does not add features. It does not change business logic. It does not “improve” the library according to my taste.
- The upstream fix is slow or uncertain. Maybe I’ve opened a pull request and it’s been sitting for two months. Maybe the library is abandoned. Maybe the maintainer has agreed to merge but won’t cut a release for a quarter. In all of these cases, I need the fix in production now, and a patch buys me time.
- The proper alternative is disproportionate. In Magento 2, the “right” way to override a vendor class involves creating a module: a directory, a
registration.php, amodule.xml, acomposer.json, a preference declaration indi.xml, and a child class. For a two-line fix to a return type, that’s a lot of ceremony. The patch wins on signal-to-noise.
When patches are the wrong tool
The most common mistake I see — and I’ve seen it from developers I otherwise respect — is using patches to extend functionality. Someone wants to add a new method to a vendor class, or hook into a flow that doesn’t have a public extension point, and they reach for a patch because it’s faster than writing a plugin.
Don’t do this. Magento 2 has a rich extension system precisely so you don’t have to. Plugins (before, after, around), preferences, observers, and event listeners exist for a reason. They’re discoverable through di.xml and events.xml. They show up in IDE navigation. They survive vendor upgrades. A patch that adds a method is invisible to your future self, breaks silently when the upstream library refactors, and turns code review into archaeology.
The mental test I use: if I removed this patch, would the library still work correctly for everyone else who uses it? If the answer is yes, I’m probably adding a feature and should write a module instead. If the answer is no — if the library is genuinely broken without my change — a patch is appropriate.
A worked example
Let me walk through a real scenario, the same one from the original post, because it’s still a good illustration.
A payment integration expects ISO 3166-1 numeric country codes as three-character strings: Belgium is 056, Ireland is 372, Ukraine is 804. The leading zero is significant — the payment gateway validates the field length and rejects anything shorter.
The library I’m using has this method:
public function getNumber(string $countryCode): int
{
$data = $this->iso3166->alpha2($countryCode);
if (isset($data['numeric'])) {
return (int) $data['numeric'];
}
throw new CountryCodeException("Country code can't be retrieved.");
}
The cast to int strips the leading zero. Belgium comes back as 56. The payment gateway rejects the request. Customers can’t check out.
I could write a preference in di.xml pointing at a child class that overrides getNumber(). That works, and for a larger fix I’d probably do it. But here the fix is exactly two characters — change int to string in the return type and the cast — and a new module feels like overkill.
The corrected method:
public function getNumber(string $countryCode): string
{
$data = $this->iso3166->alpha2($countryCode);
if (isset($data['numeric'])) {
return (string) $data['numeric'];
}
throw new CountryCodeException("Country code can't be retrieved.");
}
This is a breaking API change in isolation — the return type went from int to string — but in practice the library is broken for this use case as-is, and any caller relying on the integer value is already getting wrong data. The signature change reflects what the method should always have done.
Generating the patch
There are two workflows worth knowing. The modern one is faster; the manual one is good to understand because it shows you what’s actually happening.
The modern approach: cweagans/composer-patches with a generator
For PHP 8+ projects, install symplify/vendor-patches as a dev dependency:
composer require --dev symplify/vendor-patches
The workflow is:
- Copy the file you want to patch from
vendor/to a sibling location with a.oldsuffix. The tool uses this as the baseline. - Edit the file in
vendor/directly with your fix. - Run
vendor/bin/vendor-patches generate. - The tool produces a
.patchfile underpatches/and writes the correspondingcomposer.jsonentries for you.
For Magento 2 projects still on PHP 7.4 (and there are still plenty of them, especially older Magento 2.4.x installs), symplify/vendor-patches won’t install. You’ll need the manual approach below.
The manual approach: git diff
This is what I used for years and what still works on any project regardless of PHP version. The idea is to temporarily force-add the vendor file to Git so you can use Git’s diffing machinery to produce a clean patch.
Start by force-adding the file you want to patch. The -f flag overrides the .gitignore exclusion of vendor/:
git add -f vendor/pronko/global-payments-meta/GlobalPaymentsHppSecure/Gateway/Country.php
Verify it’s staged:
git status

Now edit the file in place. Make your fix exactly as it should appear after patching. Verify the diff looks right:
git diff vendor/pronko/global-payments-meta/GlobalPaymentsHppSecure/Gateway/Country.php
When you’re satisfied, write the diff to a patch file. I keep all patches in a patches/ directory at the project root, and I name them with the ticket ID and a short description so future-me knows what they do without opening them:
mkdir -p patches
git diff vendor/pronko/global-payments-meta/GlobalPaymentsHppSecure/Gateway/Country.php \
> patches/iso3166-country-code-as-string.patch

Now reset the vendor file so Git stops tracking it:
git reset vendor/pronko/global-payments-meta/GlobalPaymentsHppSecure/Gateway/Country.php git checkout vendor/pronko/global-payments-meta/GlobalPaymentsHppSecure/Gateway/Country.php
The reset un-stages it; the checkout discards your edits in the working tree. The patch will reapply those edits on the next composer install.
Wiring the patch into Composer
Patches don’t apply themselves. You need cweagans/composer-patches installed and configured.
Install it if you haven’t:
composer require cweagans/composer-patches
Then add the patch reference to your project’s composer.json under extra.patches. The key is the package name; the inner key is a human-readable description; the value is the path to the patch file relative to your project root:
{
"extra": {
"composer-exit-on-patch-failure": true,
"patches": {
"pronko/global-payments-meta": {
"Return ISO 3166 numeric code as string to preserve leading zero": "patches/iso3166-country-code-as-string.patch"
}
}
}
}
The composer-exit-on-patch-failure setting is important. By default, cweagans/composer-patches will log a warning and continue if a patch fails to apply. In a deployment pipeline, that means a broken patch can ship to production silently. Setting this to true makes Composer exit with a non-zero status when any patch fails, which is almost always what you want.
Apply the patch by triggering a reinstall of the patched package:
composer install
You should see output like:
- Installing pronko/global-payments-meta (1.1.0): Loading from cache
- Applying patches for pronko/global-payments-meta
patches/iso3166-country-code-as-string.patch (Return ISO 3166 numeric code as string to preserve leading zero)
If a patch fails to apply — usually because the vendor package was updated and the surrounding code shifted — Composer will tell you, and with composer-exit-on-patch-failure enabled, it’ll halt the install. That’s your cue to regenerate the patch against the new vendor version.
The maintenance burden nobody mentions
Patches are not free. Every patch you maintain is a small debt that comes due whenever the upstream library releases a new version. The patch was written against a specific version of the file. If the maintainer refactors that file — even reformatting whitespace — the patch may fail to apply.
There are a few habits that keep this manageable.
Keep patches small. A patch that touches three lines is easy to rewrite against a new vendor version. A patch that touches three hundred lines is a research project. If a patch is growing, that’s a signal to escalate it to a proper module or to push harder on getting the change upstream.
Pin vendor versions while patches are active. If you have a patch against vendor/foo at version 1.2.3, your composer.json should constrain foo to 1.2.* or even 1.2.3 exactly until you’ve verified the patch still applies to newer versions. The alternative is finding out at 2am that a transitive dependency update bumped the patched library and broke your deploy.
Track patches in your issue tracker. Every patch in my projects has a corresponding Jira ticket — usually the same ticket whose ID is in the patch filename — that captures what the fix is, why a patch was used instead of a module, whether an upstream PR exists, and what conditions would let us retire the patch. When the upstream fix lands, that ticket comes back into the sprint and we remove the patch. Without that paper trail, patches accumulate forever.
Try to get the fix upstream. Even when a patch is the right immediate move, the long-term goal should be removing it. Open a PR. File an issue. Email the maintainer. Patches are inventory, and inventory rots.
Patches versus modules versus plugins: a quick decision guide
A few rules of thumb I’ve internalized. For a small bug fix in a vendor library where the proper alternative would be a one-class module, write a patch. For anything that changes behavior across multiple files or adds non-trivial logic, write a module with a preference. For modifying the behavior of a Magento core class or a well-designed third-party class that has public methods you can wrap, write a plugin — never patch core or well-extensible code.
The hierarchy of preference, from most to least preferable: upstream fix > plugin > module with preference > patch > forking the library. Each step down is a step away from the maintainer’s intent and a step toward more long-term maintenance for you.
Closing thought
Patches are a tool, not a philosophy. They are unfashionable in some Magento circles because they were historically associated with shortcuts and bad practice, and that reputation is half-deserved. But used surgically — for small fixes to genuinely broken third-party code, with proper documentation and a clear retirement plan — they are one of the most efficient tools in the Magento toolbox.
The two-line patch that ships a checkout fix this afternoon is worth more than the perfect module that ships next week.



